diff options
| author | Christophe Simonis <chs@odoo.com> | 2019-04-12 13:37:44 +0200 |
|---|---|---|
| committer | Christophe Simonis <chs@odoo.com> | 2019-04-12 13:37:44 +0200 |
| commit | 0a09a4d8a819ef06db47e798f0465e500004e4c1 (patch) | |
| tree | b9d66d60a1e9b5ff6030be62fff89df410b744ee | |
| parent | db97bb7f1136dc3ebde9924b79364a698a63765d (diff) | |
| parent | 120359bf0c98191051ac19ae2e5fde53ca7d2ae5 (diff) | |
[MERGE] forward port branch saas-12.3 up to 120359bf0c9
239 files changed, 5060 insertions, 1946 deletions
diff --git a/addons/account/__manifest__.py b/addons/account/__manifest__.py index 4514659c9d9..3667a01f966 100644 --- a/addons/account/__manifest__.py +++ b/addons/account/__manifest__.py @@ -24,6 +24,7 @@ You could use this simplified accounting in case you work with an (external) acc 'data/digest_data.xml', 'views/account_menuitem.xml', 'views/account_payment_view.xml', + 'wizard/account_invoice_import_view.xml', 'wizard/account_unreconcile_view.xml', 'wizard/account_move_reversal_view.xml', 'views/account_move_views.xml', @@ -49,7 +50,6 @@ You could use this simplified accounting in case you work with an (external) acc 'views/tax_adjustments.xml', 'wizard/wizard_tax_adjustments_view.xml', 'views/res_config_settings_views.xml', - 'views/account_invoice_import_wizard_view.xml', 'views/account_journal_dashboard_view.xml', 'views/account_portal_templates.xml', 'views/report_payment_receipt_templates.xml', diff --git a/addons/account/i18n/account.pot b/addons/account/i18n/account.pot index 036c6a62735..b88b5f39baa 100644 --- a/addons/account/i18n/account.pot +++ b/addons/account/i18n/account.pot @@ -671,6 +671,16 @@ msgid "A tax fiscal position could be defined only one time on same taxes." msgstr "" #. module: account +#: code:addons/account/models/res_users.py:17 +#, python-format +msgid "A user cannot have both Tax B2B and Tax B2C.\n" +"Problematic user(s): %s\n" +"You should go in General Settings, and choose to display Product Prices\n" +"either in 'Tax-Included' or in 'Tax-Excluded' mode\n" +"(or switch twice the mode if you are already in the desired one)." +msgstr "" + +#. module: account #: model:res.groups,name:account.group_warning_account msgid "A warning can be set on a partner (Account)" msgstr "" diff --git a/addons/account/models/__init__.py b/addons/account/models/__init__.py index 95499de5d3d..6f178fd2665 100644 --- a/addons/account/models/__init__.py +++ b/addons/account/models/__init__.py @@ -19,4 +19,4 @@ from . import account_cash_rounding from . import reconciliation_widget from . import account_incoterms from . import digest -from . import account_invoice_import_wizard +from . import res_users diff --git a/addons/account/models/account.py b/addons/account/models/account.py index 3379aa344cc..e9f51aefc74 100644 --- a/addons/account/models/account.py +++ b/addons/account/models/account.py @@ -487,10 +487,10 @@ class AccountJournal(models.Model): bank_id = fields.Many2one('res.bank', related='bank_account_id.bank_id', readonly=False) post_at_bank_rec = fields.Boolean(string="Post At Bank Reconciliation", help="Whether or not the payments made in this journal should be generated in draft state, so that the related journal entries are only posted when performing bank reconciliation.") - # alias configuration for 'purchase' type journals + # alias configuration for journals alias_id = fields.Many2one('mail.alias', string='Alias', copy=False) alias_domain = fields.Char('Alias domain', compute='_compute_alias_domain', default=lambda self: self.env["ir.config_parameter"].sudo().get_param("mail.catchall.domain")) - alias_name = fields.Char('Alias Name for Vendor Bills', related='alias_id.alias_name', help="It creates draft vendor bill by sending an email.", readonly=False) + alias_name = fields.Char('Alias Name', related='alias_id.alias_name', help="It creates draft invoices and bills by sending an email.", readonly=False) journal_group_ids = fields.Many2many('account.journal.group', string="Journal Groups") @@ -583,13 +583,13 @@ class AccountJournal(models.Model): self.default_debit_account_id = self.default_credit_account_id @api.multi - def _get_alias_values(self, alias_name=None): + def _get_alias_values(self, type, alias_name=None): if not alias_name: alias_name = self.name if self.company_id != self.env.ref('base.main_company'): alias_name += '-' + str(self.company_id.name) return { - 'alias_defaults': {'type': 'in_invoice', 'company_id': self.company_id.id}, + 'alias_defaults': {'type': type == 'purchase' and 'in_invoice' or 'out_invoice', 'company_id': self.company_id.id}, 'alias_parent_thread_id': self.id, 'alias_name': re.sub(r'[^\w]+', '-', alias_name) } @@ -617,7 +617,7 @@ class AccountJournal(models.Model): def _update_mail_alias(self, vals): self.ensure_one() - alias_values = self._get_alias_values(alias_name=vals.get('alias_name')) + alias_values = self._get_alias_values(type=vals.get('type') or self.type, alias_name=vals.get('alias_name')) if self.alias_id: self.alias_id.write(alias_values) else: @@ -663,7 +663,7 @@ class AccountJournal(models.Model): bank_account = self.env['res.partner.bank'].browse(vals['bank_account_id']) if bank_account.partner_id != company.partner_id: raise UserError(_("The partners of the journal's company and the related bank account mismatch.")) - if vals.get('type') == 'purchase': + if 'alias_name' in vals: journal._update_mail_alias(vals) result = super(AccountJournal, self).write(vals) @@ -787,8 +787,7 @@ class AccountJournal(models.Model): if vals.get('type') in ('sale', 'purchase') and vals.get('refund_sequence') and not vals.get('refund_sequence_id'): vals.update({'refund_sequence_id': self.sudo()._create_sequence(vals, refund=True).id}) journal = super(AccountJournal, self.with_context(mail_create_nolog=True)).create(vals) - if journal.type == 'purchase': - # create a mail alias for purchase journals (always, deactivated if alias_name isn't set) + if 'alias_name' in vals: journal._update_mail_alias(vals) # Create the bank_account_id if necessary diff --git a/addons/account/models/account_bank_statement.py b/addons/account/models/account_bank_statement.py index b63543a6b0f..487477dee6a 100644 --- a/addons/account/models/account_bank_statement.py +++ b/addons/account/models/account_bank_statement.py @@ -235,14 +235,18 @@ class AccountBankStatement(models.Model): statements = self.filtered(lambda r: r.state == 'open') for statement in statements: moves = self.env['account.move'] + # `line.journal_entry_ids` gets invalidated from the cache during the loop + # because new move lines are being created at each iteration. + # The below dict is to prevent the ORM to permanently refetch `line.journal_entry_ids` + line_journal_entries = {line: line.journal_entry_ids for line in statement.line_ids} for st_line in statement.line_ids: #upon bank statement confirmation, look if some lines have the account_id set. It would trigger a journal entry #creation towards that account, with the wanted side-effect to skip that line in the bank reconciliation widget. + journal_entries = line_journal_entries[st_line] st_line.fast_counterpart_creation() - if not st_line.account_id and not st_line.journal_entry_ids.ids and not st_line.statement_id.currency_id.is_zero(st_line.amount): + if not st_line.account_id and not journal_entries.ids and not st_line.statement_id.currency_id.is_zero(st_line.amount): raise UserError(_('All the account entries lines must be processed in order to close the statement.')) - for aml in st_line.journal_entry_ids: - moves |= aml.move_id + moves = statement.mapped('line_ids.journal_entry_ids.move_id') if moves: moves.filtered(lambda m: m.state != 'posted').post() statement.message_post(body=_('Statement %s confirmed, journal items were created.') % (statement.name,)) diff --git a/addons/account/models/account_invoice.py b/addons/account/models/account_invoice.py index 4d8e5583f08..f2a5e0d785b 100644 --- a/addons/account/models/account_invoice.py +++ b/addons/account/models/account_invoice.py @@ -103,6 +103,11 @@ class AccountInvoice(models.Model): if invoice_type == 'out_invoice' and self.env['ir.config_parameter'].sudo().get_param('account.use_invoice_terms'): return self.env.user.company_id.invoice_terms + def _get_aml_for_amount_residual(self): + """ Get the aml to consider to compute the amount residual of invoices """ + self.ensure_one() + return self.sudo().move_id.line_ids.filtered(lambda l: l.account_id == self.account_id) + @api.one @api.depends( 'state', 'currency_id', 'invoice_line_ids.price_subtotal', @@ -112,14 +117,13 @@ class AccountInvoice(models.Model): residual = 0.0 residual_company_signed = 0.0 sign = self.type in ['in_refund', 'out_refund'] and -1 or 1 - for line in self.sudo().move_id.line_ids: - if line.account_id == self.account_id: - residual_company_signed += line.amount_residual - if line.currency_id == self.currency_id: - residual += line.amount_residual_currency if line.currency_id else line.amount_residual - else: - from_currency = line.currency_id or line.company_id.currency_id - residual += from_currency._convert(line.amount_residual, self.currency_id, line.company_id, line.date or fields.Date.today()) + for line in self._get_aml_for_amount_residual(): + residual_company_signed += line.amount_residual + if line.currency_id == self.currency_id: + residual += line.amount_residual_currency if line.currency_id else line.amount_residual + else: + from_currency = line.currency_id or line.company_id.currency_id + residual += from_currency._convert(line.amount_residual, self.currency_id, line.company_id, line.date or fields.Date.today()) self.residual_company_signed = abs(residual_company_signed) * sign self.residual_signed = abs(residual) * sign self.residual = abs(residual) @@ -1000,7 +1004,7 @@ class AccountInvoice(models.Model): # lots of duplicate calls to action_invoice_open, so we remove those already open to_open_invoices = self.filtered(lambda inv: inv.state != 'open') if to_open_invoices.filtered(lambda inv: not inv.partner_id): - raise UserError(_("The field Vendor is required, please complete it to validate the Vendor Bill.")) + raise UserError(_("The field Vendor/Customer is required, please complete it to validate the Vendor Bill/Customer Invoice.")) if to_open_invoices.filtered(lambda inv: inv.state != 'draft'): raise UserError(_("Invoice must be in draft state in order to validate it.")) if to_open_invoices.filtered(lambda inv: float_compare(inv.amount_total, 0.0, precision_rounding=inv.currency_id.rounding) == -1): @@ -1033,6 +1037,10 @@ class AccountInvoice(models.Model): return self.write({'state': 'open'}) @api.multi + def action_register_payment(self): + return self.env['account.payment'].with_context(active_ids=self.ids, active_model='account.invoice', active_id=self.id).action_register_payment() + + @api.multi def action_invoice_cancel(self): return self.filtered(lambda inv: inv.state != 'cancel').action_cancel() @@ -1111,11 +1119,17 @@ class AccountInvoice(models.Model): return tax_grouped @api.multi + def _get_aml_for_register_payment(self): + """ Get the aml to consider to reconcile in register payment """ + self.ensure_one() + return self.move_id.line_ids.filtered(lambda r: not r.reconciled and r.account_id.internal_type in ('payable', 'receivable')) + + @api.multi def register_payment(self, payment_line, writeoff_acc_id=False, writeoff_journal_id=False): """ Reconcile payable/receivable lines from the invoice with payment_line """ line_to_reconcile = self.env['account.move.line'] for inv in self: - line_to_reconcile += inv.move_id.line_ids.filtered(lambda r: not r.reconciled and r.account_id.internal_type in ('payable', 'receivable')) + line_to_reconcile += inv._get_aml_for_register_payment() return (line_to_reconcile + payment_line).reconcile(writeoff_acc_id, writeoff_journal_id) @api.multi diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index b9c82492446..eaa18ec5e4d 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -816,7 +816,9 @@ class AccountMoveLine(models.Model): total_amount_currency = 0 maxdate = date.min to_balance = {} + cash_basis_partial = self.env['account.partial.reconcile'] for aml in amls: + cash_basis_partial |= aml.move_id.tax_cash_basis_rec_id total_debit += aml.debit total_credit += aml.credit maxdate = max(aml.date, maxdate) @@ -831,15 +833,28 @@ class AccountMoveLine(models.Model): to_balance[aml.currency_id] = [self.env['account.move.line'], 0] to_balance[aml.currency_id][0] += aml to_balance[aml.currency_id][1] += aml.amount_residual != 0 and aml.amount_residual or aml.amount_residual_currency + # Check if reconciliation is total # To check if reconciliation is total we have 3 differents use case: # 1) There are multiple currency different than company currency, in that case we check using debit-credit # 2) We only have one currency which is different than company currency, in that case we check using amount_currency # 3) We have only one currency and some entries that don't have a secundary currency, in that case we check debit-credit # or amount_currency. + # 4) Cash basis full reconciliation + # - either none of the moves are cash basis reconciled, and we proceed + # - or some moves are cash basis reconciled and we make sure they are all fully reconciled + digits_rounding_precision = amls[0].company_id.currency_id.rounding - if (currency and float_is_zero(total_amount_currency, precision_rounding=currency.rounding)) or \ - (multiple_currency and float_compare(total_debit, total_credit, precision_rounding=digits_rounding_precision) == 0): + if ( + ( + not cash_basis_partial or (cash_basis_partial and all([p >= 1.0 for p in amls._get_matched_percentage().values()])) + ) and + ( + currency and float_is_zero(total_amount_currency, precision_rounding=currency.rounding) or + multiple_currency and float_compare(total_debit, total_credit, precision_rounding=digits_rounding_precision) == 0 + ) + ): + exchange_move_id = False # Eventually create a journal entry to book the difference due to foreign currency's exchange rate that fluctuates if to_balance and any([not float_is_zero(residual, precision_rounding=digits_rounding_precision) for aml, residual in to_balance.values()]): @@ -933,7 +948,8 @@ class AccountMoveLine(models.Model): for after_rec_dict in cash_basis_subjected: new_rec = part_rec.create(after_rec_dict) - if cash_basis: + # if the pair belongs to move being reverted, do not create CABA entry + if cash_basis and not (new_rec.debit_move_id + new_rec.credit_move_id).mapped('move_id').mapped('reverse_entry_id'): new_rec.create_tax_cash_basis_entry(cash_basis_percentage_before_rec) self.recompute() diff --git a/addons/account/models/account_payment.py b/addons/account/models/account_payment.py index 72ea7718c08..baca985def8 100644 --- a/addons/account/models/account_payment.py +++ b/addons/account/models/account_payment.py @@ -6,7 +6,6 @@ from odoo.tools import float_compare from itertools import groupby - MAP_INVOICE_TYPE_PARTNER_TYPE = { 'out_invoice': 'customer', 'out_refund': 'customer', @@ -21,6 +20,7 @@ MAP_INVOICE_TYPE_PAYMENT_SIGN = { 'out_refund': 1, } + class account_payment_method(models.Model): _name = "account.payment.method" _description = "Payment Methods" @@ -30,16 +30,36 @@ class account_payment_method(models.Model): payment_type = fields.Selection([('inbound', 'Inbound'), ('outbound', 'Outbound')], required=True) -class account_abstract_payment(models.AbstractModel): - _name = "account.abstract.payment" - _description = "Contains the logic shared between models which allows to register payments" +class account_payment(models.Model): + _name = "account.payment" + _inherit = ['mail.thread', 'mail.activity.mixin'] + _description = "Payments" + _order = "payment_date desc, name desc" + + name = fields.Char(readonly=True, copy=False) # The name is attributed upon post() + payment_reference = fields.Char(copy=False, readonly=True, help="Reference of the document used to issue this payment. Eg. check number, file name, etc.") + move_name = fields.Char(string='Journal Entry Name', readonly=True, + default=False, copy=False, + help="Technical field holding the number given to the journal entry, automatically set when the statement line is reconciled then stored to set the same number again if the line is cancelled, set to draft and re-processed again.") + + # Money flows from the journal_id's default_debit_account_id or default_credit_account_id to the destination_account_id + destination_account_id = fields.Many2one('account.account', compute='_compute_destination_account_id', readonly=True) + # For money transfer, money goes from journal_id to a transfer account, then from the transfer account to destination_journal_id + destination_journal_id = fields.Many2one('account.journal', string='Transfer To', domain=[('type', 'in', ('bank', 'cash'))], readonly=True, states={'draft': [('readonly', False)]}) - invoice_ids = fields.Many2many('account.invoice', string='Invoices', copy=False) - multi = fields.Boolean(string='Multi', - help='Technical field indicating if the user selected invoices from multiple partners or from different types.') + invoice_ids = fields.Many2many('account.invoice', 'account_invoice_payment_rel', 'payment_id', 'invoice_id', string="Invoices", copy=False, readonly=True, + help="""Technical field containing the invoice for which the payment has been generated. + This does not especially correspond to the invoices reconciled with the payment, + as it can have been generated first, and reconciled later""") + reconciled_invoice_ids = fields.Many2many('account.invoice', string='Reconciled Invoices', compute='_compute_reconciled_invoice_ids', help="Invoices whose journal items have been reconciled with this payment's.") + has_invoices = fields.Boolean(compute="_compute_reconciled_invoice_ids", help="Technical field used for usability purposes") - payment_type = fields.Selection([('outbound', 'Send Money'), ('inbound', 'Receive Money')], string='Payment Type', required=True) - payment_method_id = fields.Many2one('account.payment.method', string='Payment Method Type', required=True, oldname="payment_method", + move_line_ids = fields.One2many('account.move.line', 'payment_id', readonly=True, copy=False, ondelete='restrict') + move_reconciled = fields.Boolean(compute="_get_move_reconciled", readonly=True) + + state = fields.Selection([('draft', 'Draft'), ('posted', 'Posted'), ('sent', 'Sent'), ('reconciled', 'Reconciled'), ('cancelled', 'Cancelled')], readonly=True, default='draft', copy=False, string="Status") + payment_type = fields.Selection([('outbound', 'Send Money'), ('inbound', 'Receive Money'), ('transfer', 'Internal Transfer')], string='Payment Type', required=True, readonly=True, states={'draft': [('readonly', False)]}) + payment_method_id = fields.Many2one('account.payment.method', string='Payment Method Type', required=True, readonly=True, states={'draft': [('readonly', False)]}, oldname="payment_method", help="Manual: Get paid by cash, check or any other method outside of Odoo.\n"\ "Electronic: Get paid automatically through a payment acquirer by requesting a transaction on a card saved by the customer when buying or subscribing online (payment token).\n"\ "Check: Pay bill by check and print it from Odoo.\n"\ @@ -48,17 +68,20 @@ class account_abstract_payment(models.AbstractModel): payment_method_code = fields.Char(related='payment_method_id.code', help="Technical field used to adapt the interface to the payment type selected.", readonly=True) - partner_type = fields.Selection([('customer', 'Customer'), ('supplier', 'Vendor')], tracking=True) - partner_id = fields.Many2one('res.partner', string='Partner', tracking=True) + partner_type = fields.Selection([('customer', 'Customer'), ('supplier', 'Vendor')], tracking=True, readonly=True, states={'draft': [('readonly', False)]}) + partner_id = fields.Many2one('res.partner', string='Partner', tracking=True, readonly=True, states={'draft': [('readonly', False)]}) - amount = fields.Monetary(string='Payment Amount', required=True, tracking=True) - currency_id = fields.Many2one('res.currency', string='Currency', required=True, default=lambda self: self.env.user.company_id.currency_id) - payment_date = fields.Date(string='Payment Date', default=fields.Date.context_today, required=True, copy=False, tracking=True) - communication = fields.Char(string='Memo') - journal_id = fields.Many2one('account.journal', string='Payment Journal', required=True, tracking=True, domain=[('type', 'in', ('bank', 'cash'))]) + amount = fields.Monetary(string='Payment Amount', required=True, readonly=True, states={'draft': [('readonly', False)]}, tracking=True) + currency_id = fields.Many2one('res.currency', string='Currency', required=True, readonly=True, states={'draft': [('readonly', False)]}, default=lambda self: self.env.user.company_id.currency_id) + payment_date = fields.Date(string='Payment Date', default=fields.Date.context_today, required=True, readonly=True, states={'draft': [('readonly', False)]}, copy=False, tracking=True) + communication = fields.Char(string='Memo', readonly=True, states={'draft': [('readonly', False)]}) + journal_id = fields.Many2one('account.journal', string='Payment Journal', required=True, readonly=True, states={'draft': [('readonly', False)]}, tracking=True, domain=[('type', 'in', ('bank', 'cash'))]) + company_id = fields.Many2one('res.company', related='journal_id.company_id', string='Company', readonly=True) hide_payment_method = fields.Boolean(compute='_compute_hide_payment_method', - help="Technical field used to hide the payment method if the selected journal has only one available which is 'manual'") + help="Technical field used to hide the payment method if the" + "selected journal has only one available which is 'manual'") + payment_difference = fields.Monetary(compute='_compute_payment_difference', readonly=True) payment_difference_handling = fields.Selection([('open', 'Keep open'), ('reconcile', 'Mark invoice as fully paid')], default='open', string="Payment Difference Handling", copy=False) writeoff_account_id = fields.Many2one('account.account', string="Difference Account", domain=[('deprecated', '=', False)], copy=False) @@ -66,13 +89,14 @@ class account_abstract_payment(models.AbstractModel): string='Journal Item Label', help='Change label of the counterpart that will hold the payment difference', default='Write-Off') - partner_bank_account_id = fields.Many2one('res.partner.bank', string="Recipient Bank Account") + partner_bank_account_id = fields.Many2one('res.partner.bank', string="Recipient Bank Account", readonly=True, states={'draft': [('readonly', False)]}) show_partner_bank_account = fields.Boolean(compute='_compute_show_partner_bank', help='Technical field used to know whether the field `partner_bank_account_id` needs to be displayed or not in the payments form views') + require_partner_bank_account = fields.Boolean(compute='_compute_show_partner_bank', help='Technical field used to know whether the field `partner_bank_account_id` needs to be required or not in the payments form views') @api.model def default_get(self, fields): - rec = super(account_abstract_payment, self).default_get(fields) - active_ids = self._context.get('active_ids') + rec = super(account_payment, self).default_get(fields) + active_ids = self._context.get('active_ids') or self._context.get('active_id') active_model = self._context.get('active_model') # Check for selected invoices ids @@ -84,30 +108,16 @@ class account_abstract_payment(models.AbstractModel): # Check all invoices are open if any(invoice.state != 'open' for invoice in invoices): raise UserError(_("You can only register payments for open invoices")) - # Check all invoices have the same currency - if any(inv.currency_id != invoices[0].currency_id for inv in invoices): - raise UserError(_("In order to pay multiple invoices at once, they must use the same currency.")) - - # Look if we are mixin multiple commercial_partner or customer invoices with vendor bills - multi = any(inv.commercial_partner_id != invoices[0].commercial_partner_id - or MAP_INVOICE_TYPE_PARTNER_TYPE[inv.type] != MAP_INVOICE_TYPE_PARTNER_TYPE[invoices[0].type] - or inv.account_id != invoices[0].account_id - or inv.partner_bank_id != invoices[0].partner_bank_id - for inv in invoices) - - currency = invoices[0].currency_id - - total_amount = self._compute_payment_amount(invoices=invoices, currency=currency) + amount = self._compute_payment_amount(invoices, invoices[0].currency_id) rec.update({ - 'amount': abs(total_amount), - 'currency_id': currency.id, - 'payment_type': total_amount > 0 and 'inbound' or 'outbound', - 'partner_id': False if multi else invoices[0].commercial_partner_id.id, - 'partner_type': False if multi else MAP_INVOICE_TYPE_PARTNER_TYPE[invoices[0].type], - 'communication': ' '.join([ref for ref in invoices.mapped('reference') if ref]), + 'currency_id': invoices[0].currency_id.id, + 'amount': abs(amount), + 'payment_type': 'inbound' if amount > 0 else 'outbound', + 'partner_id': invoices[0].commercial_partner_id.id, + 'partner_type': MAP_INVOICE_TYPE_PARTNER_TYPE[invoices[0].type], + 'communication': invoices[0].reference or invoices[0].number, 'invoice_ids': [(6, 0, invoices.ids)], - 'multi': multi, }) return rec @@ -121,12 +131,17 @@ class account_abstract_payment(models.AbstractModel): def _get_method_codes_using_bank_account(self): return [] + @api.model + def _get_method_codes_needing_bank_account(self): + return [] + @api.depends('payment_method_code') def _compute_show_partner_bank(self): """ Computes if the destination bank account must be displayed in the payment form view. By default, it won't be displayed but some modules might change that, depending on the payment type.""" for payment in self: - payment.show_partner_bank_account = payment.payment_method_code in self._get_method_codes_using_bank_account() and not self.multi + payment.show_partner_bank_account = payment.payment_method_code in self._get_method_codes_using_bank_account() + payment.require_partner_bank_account = payment.payment_method_code in self._get_method_codes_needing_bank_account() @api.multi @api.depends('payment_type', 'journal_id') @@ -140,9 +155,9 @@ class account_abstract_payment(models.AbstractModel): or payment.journal_id.outbound_payment_method_ids payment.hide_payment_method = len(journal_payment_methods) == 1 and journal_payment_methods[0].code == 'manual' - @api.depends('invoice_ids', 'amount', 'payment_date', 'currency_id') + @api.depends('invoice_ids', 'amount', 'payment_date', 'currency_id', 'payment_type') def _compute_payment_difference(self): - for pay in self.filtered(lambda p: p.invoice_ids): + for pay in self.filtered(lambda p: p.invoice_ids and p.state == 'draft'): payment_amount = -pay.amount if pay.payment_type == 'outbound' else pay.amount pay.payment_difference = pay._compute_payment_amount() - payment_amount @@ -162,12 +177,20 @@ class account_abstract_payment(models.AbstractModel): # Set payment method domain (restrict to methods enabled for the journal and to selected payment type) payment_type = self.payment_type in ('outbound', 'transfer') and 'outbound' or 'inbound' - return {'domain': {'payment_method_id': [('payment_type', '=', payment_type), ('id', 'in', payment_methods_list)]}} + + domain = {'payment_method_id': [('payment_type', '=', payment_type), ('id', 'in', payment_methods_list)]} + + if self.env.context.get('active_model') == 'account.invoice': + active_ids = self._context.get('active_ids') + invoices = self.env['account.invoice'].browse(active_ids) + self.amount = abs(self._compute_payment_amount(invoices)) + + return {'domain': domain} return {} @api.onchange('partner_id') def _onchange_partner_id(self): - if not self.multi and self.invoice_ids and self.invoice_ids[0].partner_bank_id: + if self.invoice_ids and self.invoice_ids[0].partner_bank_id: self.partner_bank_account_id = self.invoice_ids[0].partner_bank_id elif self.partner_id != self.partner_bank_account_id.partner_id: # This condition ensures we use the default value provided into @@ -182,9 +205,38 @@ class account_abstract_payment(models.AbstractModel): self.partner_bank_account_id = False return {'domain': {'partner_bank_account_id': [('partner_id', 'in', [self.partner_id.id, self.partner_id.commercial_partner_id.id])]}} + @api.onchange('partner_type') + def _onchange_partner_type(self): + self.ensure_one() + # Set partner_id domain + if self.partner_type: + return {'domain': {'partner_id': [(self.partner_type, '=', True)]}} + + @api.onchange('payment_type') + def _onchange_payment_type(self): + if not self.invoice_ids and not self.partner_type: + # Set default partner type for the payment type + if self.payment_type == 'inbound': + self.partner_type = 'customer' + elif self.payment_type == 'outbound': + self.partner_type = 'supplier' + elif self.payment_type not in ('inbound', 'outbound'): + self.partner_type = False + # Set payment method domain + res = self._onchange_journal() + if not res.get('domain', {}): + res['domain'] = {} + jrnl_filters = self._compute_journal_domain_and_types() + journal_types = jrnl_filters['journal_types'] + journal_types.update(['bank', 'cash']) + res['domain']['journal_id'] = jrnl_filters['domain'] + [('type', 'in', list(journal_types))] + return res + def _compute_journal_domain_and_types(self): journal_type = ['bank', 'cash'] domain = [] + if self.invoice_ids: + domain.append(('company_id', '=', self.invoice_ids[0].company_id.id)) if self.currency_id.is_zero(self.amount) and self.has_invoices: # In case of payment with 0 amount, allow to select a journal of type 'general' like # 'Miscellaneous Operations' and set this journal by default. @@ -202,7 +254,9 @@ class account_abstract_payment(models.AbstractModel): jrnl_filters = self._compute_journal_domain_and_types() journal_types = jrnl_filters['journal_types'] domain_on_types = [('type', 'in', list(journal_types))] - if self.journal_id.type not in journal_types: + if self.invoice_ids: + domain_on_types.append(('company_id', '=', self.invoice_ids[0].company_id.id)) + if self.journal_id.type not in journal_types or (self.invoice_ids and self.journal_id.company_id != self.invoice_ids[0].company_id): self.journal_id = self.env['account.journal'].search(domain_on_types, limit=1) return {'domain': {'journal_id': jrnl_filters['domain'] + domain_on_types}} @@ -210,11 +264,14 @@ class account_abstract_payment(models.AbstractModel): def _onchange_currency(self): self.amount = abs(self._compute_payment_amount()) - # Set by default the first liquidity journal having this currency if exists. - if self.journal_id: + if self.journal_id: # TODO: only return if currency differ? return - journal = self.env['account.journal'].search( - [('type', 'in', ('bank', 'cash')), ('currency_id', '=', self.currency_id.id)], limit=1) + + # Set by default the first liquidity journal having this currency if exists. + domain = [('type', 'in', ('bank', 'cash')), ('currency_id', '=', self.currency_id.id)] + if self.invoice_ids: + domain.append(('company_id', '=', self.invoice_ids[0].company_id.id)) + journal = self.env['account.journal'].search(domain, limit=1) if journal: return {'value': {'journal_id': journal.id}} @@ -233,7 +290,7 @@ class account_abstract_payment(models.AbstractModel): # Get the payment currency if not currency: - currency = self.currency_id or self.journal_id.currency_id or self.journal_id.company_id.currency_id or invoices and invoices[0].currency_id + currency = self.currency_id or self.journal_id.currency_id or self.journal_id.company_id.currency_id # Avoid currency rounding issues by summing the amounts according to the company_currency_id before invoice_datas = invoices.read_group( @@ -250,154 +307,6 @@ class account_abstract_payment(models.AbstractModel): total += payment_currency._convert(amount_total, currency, self.env.user.company_id, self.payment_date or fields.Date.today()) return total - -class account_register_payments(models.TransientModel): - _name = "account.register.payments" - _inherit = 'account.abstract.payment' - _description = "Register Payments" - - group_invoices = fields.Boolean(string="Group Invoices", help="""If enabled, groups invoices by commercial partner, invoice account, - type and recipient bank account in the generated payments. If disabled, - a distinct payment will be generated for each invoice.""") - show_communication_field = fields.Boolean(compute='_compute_show_communication_field') - - @api.depends('invoice_ids.partner_id', 'group_invoices') - def _compute_show_communication_field(self): - """ We allow choosing a common communication for payments if the group - option has been activated, and all the invoices relate to the same - partner. - """ - for record in self: - record.show_communication_field = len(record.invoice_ids) == 1 \ - or record.group_invoices and len(record.mapped('invoice_ids.partner_id.commercial_partner_id')) == 1 - - @api.onchange('journal_id') - def _onchange_journal(self): - res = super(account_register_payments, self)._onchange_journal() - active_ids = self._context.get('active_ids') - invoices = self.env['account.invoice'].browse(active_ids) - self.amount = abs(self._compute_payment_amount(invoices)) - return res - - @api.model - def default_get(self, fields): - rec = super(account_register_payments, self).default_get(fields) - active_ids = self._context.get('active_ids') - - if not active_ids: - raise UserError(_("Programming error: wizard action executed without active_ids in context.")) - - return rec - - @api.multi - def _groupby_invoices(self): - '''Groups the invoices linked to the wizard. - - If the group_invoices option is activated, invoices will be grouped - according to their commercial partner, their account, their type and - the account where the payment they expect should end up. Otherwise, - invoices will be grouped so that each of them belongs to a - distinct group. - - :return: a dictionary mapping, grouping invoices as a recordset under each of its keys. - ''' - if not self.group_invoices: - return {inv.id: inv for inv in self.invoice_ids} - - results = {} - # Create a dict dispatching invoices according to their commercial_partner_id, account_id, invoice_type and partner_bank_id - for inv in self.invoice_ids: - partner_id = inv.commercial_partner_id.id - account_id = inv.account_id.id - invoice_type = MAP_INVOICE_TYPE_PARTNER_TYPE[inv.type] - recipient_account = inv.partner_bank_id - key = (partner_id, account_id, invoice_type, recipient_account) - if not key in results: - results[key] = self.env['account.invoice'] - results[key] += inv - return results - - @api.multi - def _prepare_payment_vals(self, invoices): - '''Create the payment values. - - :param invoices: The invoices that should have the same commercial partner and the same type. - :return: The payment values as a dictionary. - ''' - amount = self._compute_payment_amount(invoices=invoices) if self.multi else self.amount - payment_type = ('inbound' if amount > 0 else 'outbound') if self.multi else self.payment_type - bank_account = self.multi and invoices[0].partner_bank_id or self.partner_bank_account_id - pmt_communication = self.show_communication_field and self.communication \ - or self.group_invoices and ' '.join([inv.reference or inv.number for inv in invoices]) \ - or invoices[0].reference # in this case, invoices contains only one element, since group_invoices is False - values = { - 'journal_id': self.journal_id.id, - 'payment_method_id': self.payment_method_id.id, - 'payment_date': self.payment_date, - 'communication': pmt_communication, - 'invoice_ids': [(6, 0, invoices.ids)], - 'payment_type': payment_type, - 'amount': abs(amount), - 'currency_id': self.currency_id.id, - 'partner_id': invoices[0].commercial_partner_id.id, - 'partner_type': MAP_INVOICE_TYPE_PARTNER_TYPE[invoices[0].type], - 'partner_bank_account_id': bank_account.id, - 'multi': False, - 'payment_difference_handling': self.payment_difference_handling, - 'writeoff_account_id': self.writeoff_account_id.id, - 'writeoff_label': self.writeoff_label, - } - - return values - - @api.multi - def get_payments_vals(self): - '''Compute the values for payments. - - :return: a list of payment values (dictionary). - ''' - if self.multi: - groups = self._groupby_invoices() - return [self._prepare_payment_vals(invoices) for invoices in groups.values()] - return [self._prepare_payment_vals(self.invoice_ids)] - - @api.multi - def create_payments(self): - '''Create payments according to the invoices. - Having invoices with different commercial_partner_id or different type (Vendor bills with customer invoices) - leads to multiple payments. - In case of all the invoices are related to the same commercial_partner_id and have the same type, - only one payment will be created. - - :return: The ir.actions.act_window to show created payments. - ''' - Payment = self.env['account.payment'] - payments = Payment - for payment_vals in self.get_payments_vals(): - payments += Payment.create(payment_vals) - payments.post() - - action_vals = { - 'name': _('Payments'), - 'domain': [('id', 'in', payments.ids), ('state', '=', 'posted')], - 'view_type': 'form', - 'res_model': 'account.payment', - 'view_id': False, - 'type': 'ir.actions.act_window', - } - if len(payments) == 1: - action_vals.update({'res_id': payments[0].id, 'view_mode': 'form'}) - else: - action_vals['view_mode'] = 'tree,form' - return action_vals - - -class account_payment(models.Model): - _name = "account.payment" - _inherit = ['mail.thread', 'mail.activity.mixin', 'account.abstract.payment'] - _description = "Payments" - _order = "payment_date desc, name desc" - @api.multi def name_get(self): return [(payment.id, payment.name or _('Draft Payment')) for payment in self] @@ -410,40 +319,16 @@ class account_payment(models.Model): for aml in payment.move_line_ids.filtered(lambda x: x.account_id.reconcile): if not aml.reconciled: rec = False + break payment.move_reconciled = rec - company_id = fields.Many2one('res.company', related='journal_id.company_id', string='Company', readonly=True) - name = fields.Char(readonly=True, copy=False) # The name is attributed upon post() - state = fields.Selection([('draft', 'Draft'), ('posted', 'Posted'), ('sent', 'Sent'), ('reconciled', 'Reconciled'), ('cancelled', 'Cancelled')], readonly=True, default='draft', copy=False, string="Status") - - payment_type = fields.Selection(selection_add=[('transfer', 'Internal Transfer')]) - payment_reference = fields.Char(copy=False, readonly=True, help="Reference of the document used to issue this payment. Eg. check number, file name, etc.") - move_name = fields.Char(string='Journal Entry Name', readonly=True, - default=False, copy=False, - help="Technical field holding the number given to the journal entry, automatically set when the statement line is reconciled then stored to set the same number again if the line is cancelled, set to draft and re-processed again.") - - # Money flows from the journal_id's default_debit_account_id or default_credit_account_id to the destination_account_id - destination_account_id = fields.Many2one('account.account', compute='_compute_destination_account_id', readonly=True) - # For money transfer, money goes from journal_id to a transfer account, then from the transfer account to destination_journal_id - destination_journal_id = fields.Many2one('account.journal', string='Transfer To', domain=[('type', 'in', ('bank', 'cash'))]) - - invoice_ids = fields.Many2many('account.invoice', 'account_invoice_payment_rel', 'payment_id', 'invoice_id', string="Invoices", copy=False, readonly=True, help="""Technical field containing the invoices for which the payment has been generated. - This does not especially correspond to the invoices reconciled with the payment, - as it can have been generated first, and reconciled later""") - reconciled_invoice_ids = fields.Many2many('account.invoice', string='Reconciled Invoices', compute='_compute_reconciled_invoice_ids', help="Invoices whose journal items have been reconciled with this payment's.") - has_invoices = fields.Boolean(compute="_compute_reconciled_invoice_ids", help="Technical field used for usability purposes") - - # FIXME: ondelete='restrict' not working (eg. cancel a bank statement reconciliation with a payment) - move_line_ids = fields.One2many('account.move.line', 'payment_id', readonly=True, copy=False, ondelete='restrict') - move_reconciled = fields.Boolean(compute="_get_move_reconciled", readonly=True) - def open_payment_matching_screen(self): # Open reconciliation view for customers/suppliers move_line_id = False for move_line in self.move_line_ids: if move_line.account_id.reconcile: move_line_id = move_line.id - break; + break if not self.partner_id: raise UserError(_("Payments without a customer can't be matched")) action_context = {'company_ids': [self.company_id.id], 'partner_ids': [self.partner_id.commercial_partner_id.id]} @@ -484,61 +369,25 @@ class account_payment(models.Model): def _compute_reconciled_invoice_ids(self): for record in self: record.reconciled_invoice_ids = (record.move_line_ids.mapped('matched_debit_ids.debit_move_id.invoice_id') | - record.move_line_ids.mapped('matched_credit_ids.credit_move_id.invoice_id')) + record.move_line_ids.mapped('matched_credit_ids.credit_move_id.invoice_id')) record.has_invoices = bool(record.reconciled_invoice_ids) - @api.onchange('partner_type') - def _onchange_partner_type(self): - self.ensure_one() - # Set partner_id domain - if self.partner_type: - return {'domain': {'partner_id': [(self.partner_type, '=', True)]}} - - @api.onchange('payment_type') - def _onchange_payment_type(self): - if not self.invoice_ids and not self.partner_type: - # Set default partner type for the payment type - if self.payment_type == 'inbound': - self.partner_type = 'customer' - elif self.payment_type == 'outbound': - self.partner_type = 'supplier' - elif self.payment_type not in ('inbound', 'outbound'): - self.partner_type = False - # Set payment method domain - res = self._onchange_journal() - if not res.get('domain', {}): - res['domain'] = {} - jrnl_filters = self._compute_journal_domain_and_types() - journal_types = jrnl_filters['journal_types'] - journal_types.update(['bank', 'cash']) - res['domain']['journal_id'] = jrnl_filters['domain'] + [('type', 'in', list(journal_types))] - return res - - @api.model - def default_get(self, fields): - rec = super(account_payment, self).default_get(fields) - invoice_defaults = self.resolve_2many_commands('invoice_ids', rec.get('invoice_ids')) - if invoice_defaults and len(invoice_defaults) == 1: - invoice = invoice_defaults[0] - rec['communication'] = invoice['reference'] or invoice['name'] or invoice['number'] - rec['currency_id'] = invoice['currency_id'][0] - rec['payment_type'] = invoice['type'] in ('out_invoice', 'in_refund') and 'inbound' or 'outbound' - rec['partner_type'] = MAP_INVOICE_TYPE_PARTNER_TYPE[invoice['type']] - rec['partner_id'] = invoice['partner_id'][0] - rec['amount'] = invoice['residual'] - return rec + @api.multi + def action_register_payment(self): + active_ids = self.env.context.get('active_ids') + if not active_ids: + return '' - @api.model - def create(self, vals): - rslt = super(account_payment, self).create(vals) - # When a payment is created by the multi payments wizard in 'multi' mode, - # its partner_bank_account_id will never be displayed, and hence stay empty, - # even if the payment method requires it. This condition ensures we set - # the first (and thus most prioritary) account of the partner in this field - # in that situation. - if not rslt.partner_bank_account_id and rslt.show_partner_bank_account and rslt.partner_id.bank_ids: - rslt.partner_bank_account_id = rslt.partner_id.bank_ids[0] - return rslt + return { + 'name': _('Register Payment'), + 'res_model': len(active_ids) == 1 and 'account.payment' or 'account.payment.register', + 'view_type': 'form', + 'view_mode': 'form', + 'view_id': len(active_ids) != 1 and self.env.ref('account.view_account_payment_form_multi').id or self.env.ref('account.view_account_payment_invoice_form').id, + 'context': self.env.context, + 'target': 'new', + 'type': 'ir.actions.act_window', + } @api.multi def button_journal_entries(self): @@ -570,10 +419,6 @@ class account_payment(models.Model): } @api.multi - def button_dummy(self): - return True - - @api.multi def unreconcile(self): """ Set back the payments in 'posted' or 'sent' state, without deleting the journal entries. Called when cancelling a bank statement line linked to a pre-registered payment. @@ -588,7 +433,7 @@ class account_payment(models.Model): def cancel(self): for rec in self: for move in rec.move_line_ids.mapped('move_id'): - if rec.invoice_ids: + if rec.reconciled_invoice_ids: move.line_ids.remove_move_reconcile() move.button_cancel() move.unlink() @@ -656,17 +501,6 @@ class account_payment(models.Model): def action_draft(self): return self.write({'state': 'draft'}) - def action_validate_invoice_payment(self): - """ Posts a payment used to pay an invoice. This function only posts the - payment by default but can be overridden to apply specific post or pre-processing. - It is called by the "validate" button of the popup window - triggered on invoice form by the "Register Payment" button. - """ - if any(len(record.invoice_ids) != 1 for record in self): - # For multiple invoices, there is account.register.payments wizard - raise UserError(_("This method should only be called to process a single invoice's payment.")) - return self.post() - def _create_payment_entry(self, amount): """ Create a journal entry corresponding to a payment, if the payment references invoice(s) they are reconciled. Return the journal entry. @@ -810,7 +644,7 @@ class account_payment(models.Model): name = _('Transfer to %s') % self.destination_journal_id.name vals = { 'name': name, - 'account_id': self.payment_type in ('outbound','transfer') and self.journal_id.default_debit_account_id.id or self.journal_id.default_credit_account_id.id, + 'account_id': self.payment_type in ('outbound', 'transfer') and self.journal_id.default_debit_account_id.id or self.journal_id.default_credit_account_id.id, 'journal_id': self.journal_id.id, 'currency_id': self.currency_id != self.company_id.currency_id and self.currency_id.id or False, } @@ -825,3 +659,118 @@ class account_payment(models.Model): }) return vals + + +class payment_register(models.TransientModel): + _name = 'account.payment.register' + _description = 'Register Payment' + + payment_date = fields.Date(required=True, default=fields.Date.context_today) + journal_id = fields.Many2one('account.journal', required=True, domain=[('type', 'in', ('bank', 'cash'))]) + payment_method_id = fields.Many2one('account.payment.method', string='Payment Method Type', required=True, + help="Manual: Get paid by cash, check or any other method outside of Odoo.\n" + "Electronic: Get paid automatically through a payment acquirer by requesting a transaction on a card saved by the customer when buying or subscribing online (payment token).\n" + "Check: Pay bill by check and print it from Odoo.\n" + "Batch Deposit: Encase several customer checks at once by generating a batch deposit to submit to your bank. When encoding the bank statement in Odoo, you are suggested to reconcile the transaction with the batch deposit.To enable batch deposit, module account_batch_payment must be installed.\n" + "SEPA Credit Transfer: Pay bill from a SEPA Credit Transfer file you submit to your bank. To enable sepa credit transfer, module account_sepa must be installed ") + invoice_ids = fields.Many2many('account.invoice', 'account_invoice_payment_rel_transient', 'payment_id', 'invoice_id', string="Invoices", copy=False, readonly=True) + + @api.model + def default_get(self, fields): + rec = super(payment_register, self).default_get(fields) + active_ids = self._context.get('active_ids') + active_model = self._context.get('active_model') + invoices = self.env['account.invoice'].browse(active_ids) + + # Check all invoices are open + if any(invoice.state != 'open' for invoice in invoices): + raise UserError(_("You can only register payments for open invoices")) + # Check all invoices are inbound or all invoices are outbound + outbound_list = [invoice.type in ('in_invoice', 'out_refund') for invoice in invoices] + first_outbound = invoices[0].type in ('in_invoice', 'out_refund') + if any(x != first_outbound for x in outbound_list): + raise UserError(_("You can only register at the same time for payment that are all inbound or all outbound")) + if 'invoice_ids' not in rec: + rec['invoice_ids'] = [(6, 0, invoices.ids)] + if 'journal_id' not in rec: + rec['journal_id'] = self.env['account.journal'].search([('company_id', '=', self.env.user.company_id.id), ('type', 'in', ('bank', 'cash'))], limit=1).id + if 'payment_method_id' not in rec: + if invoices[0].type in ('out_invoice', 'in_refund'): + domain = [('payment_type', '=', 'inbound')] + else: + domain = [('payment_type', '=', 'outbound')] + rec['payment_method_id'] = self.env['account.payment.method'].search(domain, limit=1).id + return rec + + @api.onchange('journal_id', 'invoice_ids') + def _onchange_journal(self): + active_ids = self._context.get('active_ids') + invoices = self.env['account.invoice'].browse(active_ids) + if self.journal_id and invoices: + if invoices[0].type in ('out_invoice', 'in_refund'): + domain = [('payment_type', '=', 'inbound'), ('id', 'in', self.journal_id.inbound_payment_method_ids.ids)] + else: + domain = [('payment_type', '=', 'outbound'), ('id', 'in', self.journal_id.outbound_payment_method_ids.ids)] + + return {'domain': {'payment_method_id': domain}} + return {} + + @api.multi + def _prepare_payment_vals(self, invoice): + '''Create the payment values. + + :param invoice: A single invoice/bill to pay. + :return: The payment values as a dictionary. + ''' + amount = self.env['account.payment']._compute_payment_amount(invoices=invoice, currency=invoice.currency_id) + values = { + 'journal_id': self.journal_id.id, + 'payment_method_id': self.payment_method_id.id, + 'payment_date': self.payment_date, + 'communication': invoice.reference or invoice.number, + 'invoice_ids': [(6, 0, invoice.ids)], + 'payment_type': ('inbound' if amount > 0 else 'outbound'), + 'amount': abs(amount), + 'currency_id': invoice.currency_id.id, + 'partner_id': invoice.commercial_partner_id.id, + 'partner_type': MAP_INVOICE_TYPE_PARTNER_TYPE[invoice.type], + 'partner_bank_account_id': invoice.partner_bank_id.id, + } + return values + + @api.multi + def get_payments_vals(self): + '''Compute the values for payments. + + :return: a list of payment values (dictionary). + ''' + return [self._prepare_payment_vals(invoice) for invoice in self.invoice_ids] + + @api.multi + def create_payments(self): + '''Create payments according to the invoices. + Having invoices with different commercial_partner_id or different type + (Vendor bills with customer invoices) leads to multiple payments. + In case of all the invoices are related to the same + commercial_partner_id and have the same type, only one payment will be + created. + + :return: The ir.actions.act_window to show created payments. + ''' + Payment = self.env['account.payment'] + payments = Payment.create(self.get_payments_vals()) + payments.post() + + action_vals = { + 'name': _('Payments'), + 'domain': [('id', 'in', payments.ids), ('state', '=', 'posted')], + 'view_type': 'form', + 'res_model': 'account.payment', + 'view_id': False, + 'type': 'ir.actions.act_window', + } + if len(payments) == 1: + action_vals.update({'res_id': payments[0].id, 'view_mode': 'form'}) + else: + action_vals['view_mode'] = 'tree,form' + return action_vals diff --git a/addons/account/models/reconciliation_widget.py b/addons/account/models/reconciliation_widget.py index 875a346dabd..4683afab86f 100644 --- a/addons/account/models/reconciliation_widget.py +++ b/addons/account/models/reconciliation_widget.py @@ -95,6 +95,17 @@ class AccountReconciliation(models.AbstractModel): WHERE st_line.id IN %s ''' params = [tuple(st_lines.ids)] + + # Add the res.partner's IR rules to the WHERE clause. In case partners are not shared + # between companies, identical partners may exist in a company we don't have access to. + ir_rules_query = self.env['res.partner']._where_calc([]) + self.env['res.partner']._apply_ir_rules(ir_rules_query, 'read') + from_clause, where_clause, where_clause_params = ir_rules_query.get_sql() + where_p3 = (" AND %s" % where_clause).replace('res_partner', 'p3') if where_clause else '' + if where_p3: + query += where_p3 + params += where_clause_params + self._cr.execute(query, params) result = {} diff --git a/addons/account/models/res_users.py b/addons/account/models/res_users.py new file mode 100644 index 00000000000..ab85e359d22 --- /dev/null +++ b/addons/account/models/res_users.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, models, _ +from odoo.exceptions import ValidationError + + +class Users(models.Model): + _inherit = "res.users" + + @api.multi + @api.constrains('groups_id') + def _check_one_user_type(self): + super(Users, self)._check_one_user_type() + + users_with_both_groups = self.filtered(lambda user: + user.user_has_groups('account.group_show_line_subtotals_tax_included') and + user.user_has_groups('account.group_show_line_subtotals_tax_excluded') + ) + if users_with_both_groups: + names = ", ".join(users_with_both_groups.mapped('name')) + raise ValidationError(_("A user cannot have both Tax B2B and Tax B2C.\n" + "Problematic user(s): %s\n" + "You should go in General Settings, and choose to display Product Prices\n" + "either in 'Tax-Included' or in 'Tax-Excluded' mode\n" + "(or switch twice the mode if you are already in the desired one).") % names) diff --git a/addons/account/security/account_security.xml b/addons/account/security/account_security.xml index 5e5a63f120f..bad495b9290 100644 --- a/addons/account/security/account_security.xml +++ b/addons/account/security/account_security.xml @@ -25,18 +25,6 @@ <field name="category_id" ref="base.module_category_hidden"/> </record> - <record id="base.group_public" model="res.groups"> - <field name="implied_ids" eval="[(4, ref('account.group_show_line_subtotals_tax_excluded'))]"/> - </record> - - <record id="base.group_portal" model="res.groups"> - <field name="implied_ids" eval="[(4, ref('account.group_show_line_subtotals_tax_excluded'))]"/> - </record> - - <record id="base.group_user" model="res.groups"> - <field name="implied_ids" eval="[(4, ref('account.group_show_line_subtotals_tax_excluded'))]"/> - </record> - <record id="group_account_user" model="res.groups"> <field name="name">Show Full Accounting Features</field> <field name="category_id" ref="base.module_category_hidden"/> @@ -80,6 +68,16 @@ <data noupdate="1"> + <record id="base.group_public" model="res.groups"> + <field name="implied_ids" eval="[(4, ref('account.group_show_line_subtotals_tax_excluded'))]"/> + </record> + <record id="base.group_portal" model="res.groups"> + <field name="implied_ids" eval="[(4, ref('account.group_show_line_subtotals_tax_excluded'))]"/> + </record> + <record id="base.group_user" model="res.groups"> + <field name="implied_ids" eval="[(4, ref('account.group_show_line_subtotals_tax_excluded'))]"/> + </record> + <record id="base.default_user" model="res.users"> <field name="groups_id" eval="[(4,ref('account.group_account_manager'))]"/> </record> diff --git a/addons/account/static/src/js/bills_tree_upload.js b/addons/account/static/src/js/bills_tree_upload.js index edc043a5978..5171051bb97 100644 --- a/addons/account/static/src/js/bills_tree_upload.js +++ b/addons/account/static/src/js/bills_tree_upload.js @@ -22,7 +22,6 @@ odoo.define('account.bills.tree', function (require) { this.$buttons.on('click', '.o_button_upload_bill', function () { var state = self.model.get(self.handle, {raw: true}); var context = state.getContext() - context['type'] = 'in_invoice' self.do_action({ type: 'ir.actions.act_window', res_model: 'account.invoice.import.wizard', @@ -41,5 +40,5 @@ odoo.define('account.bills.tree', function (require) { }), }); - viewRegistry.add('account_bills_tree', BillsListView); + viewRegistry.add('account_tree', BillsListView); }); diff --git a/addons/account/static/src/js/reconciliation/reconciliation_action.js b/addons/account/static/src/js/reconciliation/reconciliation_action.js index 6c4fb9c912a..67d5de9de08 100644 --- a/addons/account/static/src/js/reconciliation/reconciliation_action.js +++ b/addons/account/static/src/js/reconciliation/reconciliation_action.js @@ -259,7 +259,7 @@ var StatementAction = AbstractAction.extend({ } }); }, - + /** * @private * @param {OdooEvent} ev @@ -308,7 +308,7 @@ var StatementAction = AbstractAction.extend({ return this.model.closeStatement().then(function (result) { self.do_action({ name: 'Bank Statements', - res_model: 'account.bank.statement', + res_model: 'account.bank.statement.line', res_id: result, views: [[false, 'form']], type: 'ir.actions.act_window', diff --git a/addons/account/static/src/js/reconciliation/reconciliation_renderer.js b/addons/account/static/src/js/reconciliation/reconciliation_renderer.js index 7e432a0e0a5..33b4430ab2f 100644 --- a/addons/account/static/src/js/reconciliation/reconciliation_renderer.js +++ b/addons/account/static/src/js/reconciliation/reconciliation_renderer.js @@ -573,7 +573,10 @@ var LineRenderer = Widget.extend(FieldManagerMixin, { type: 'boolean', name: 'to_check', }], { - account_id: {string: _t("Account")}, + account_id: { + string: _t("Account"), + domain: [['deprecated', '=', false]], + }, label: {string: _t("Label")}, amount: {string: _t("Account")}, }).then(function (recordID) { diff --git a/addons/account/static/src/xml/account_reconciliation.xml b/addons/account/static/src/xml/account_reconciliation.xml index d76ef05e917..2555b348990 100644 --- a/addons/account/static/src/xml/account_reconciliation.xml +++ b/addons/account/static/src/xml/account_reconciliation.xml @@ -67,7 +67,7 @@ <t t-if="context.journal_id"> <button class="button_back_to_statement btn btn-secondary" t-att-data_journal_id='context.journal_id'>Go to bank statement(s)</button> </t> - <t t-if="context['active_model'] === 'account.bank.statement'"> + <t t-if="context['active_model'] === 'account.bank.statement.line'"> <button class="button_close_statement btn btn-primary" style="display: inline-block;">Close statement</button> </t> </p> @@ -236,7 +236,7 @@ <t t-name="reconciliation.line.mv_line.amount"> <span t-att-class="(line.is_move_line && proposition == true) ? 'cell' : ''"> - + <span class="line_amount"> <span t-if="line.amount_currency_str" t-attf-class="o_multi_currency o_multi_currency_color_#{line.currency_id%8} line_info_button fa fa-money" diff --git a/addons/account/static/tests/reconciliation_tests.js b/addons/account/static/tests/reconciliation_tests.js index 87fbddd31eb..5e8d71b466d 100644 --- a/addons/account/static/tests/reconciliation_tests.js +++ b/addons/account/static/tests/reconciliation_tests.js @@ -54,6 +54,7 @@ var db = { code: {string: "code", type: 'integer'}, name: {string: "Displayed name", type: 'char'}, company_id: {string: "Company", type: 'many2one', relation: 'res.company'}, + deprecated: {string: "Deprecated", type: 'boolean'}, }, records: [ {id: 282, code: 100000, name: "100000 Fixed Asset Account", company_id: 1}, diff --git a/addons/account/tests/test_payment.py b/addons/account/tests/test_payment.py index c010d0084aa..808393d89a5 100644 --- a/addons/account/tests/test_payment.py +++ b/addons/account/tests/test_payment.py @@ -1,5 +1,6 @@ from odoo.addons.account.tests.account_test_classes import AccountingTestCase from odoo.tests import tagged +from odoo.tests.common import Form import time @@ -8,7 +9,7 @@ class TestPayment(AccountingTestCase): def setUp(self): super(TestPayment, self).setUp() - self.register_payments_model = self.env['account.register.payments'].with_context(active_model='account.invoice') + self.register_payments_model = self.env['account.payment.register'].with_context(active_model='account.invoice') self.payment_model = self.env['account.payment'] self.invoice_model = self.env['account.invoice'] self.invoice_line_model = self.env['account.invoice.line'] @@ -41,6 +42,8 @@ class TestPayment(AccountingTestCase): self.diff_income_account = self.env['res.users'].browse(self.env.uid).company_id.income_currency_exchange_account_id self.diff_expense_account = self.env['res.users'].browse(self.env.uid).company_id.expense_currency_exchange_account_id + self.form_payment = Form(self.env['account.payment']) + def create_invoice(self, amount=100, type='out_invoice', currency_id=None, partner=None, account_id=None): """ Returns an open invoice """ invoice = self.invoice_model.create({ @@ -82,12 +85,11 @@ class TestPayment(AccountingTestCase): return bank_stmt def test_full_payment_process(self): - """ Create a payment for two invoices, post it and reconcile it with a bank statement """ - inv_1 = self.create_invoice(amount=100, currency_id=self.currency_eur_id, partner=self.partner_agrolait.id) - inv_2 = self.create_invoice(amount=200, currency_id=self.currency_eur_id, partner=self.partner_agrolait.id) + """ Create a payment for one invoice, post it and reconcile it with a bank statement """ + inv_1 = self.create_invoice(amount=300, currency_id=self.currency_eur_id, partner=self.partner_agrolait.id) - ctx = {'active_model': 'account.invoice', 'active_ids': [inv_1.id, inv_2.id]} - register_payments = self.register_payments_model.with_context(ctx).create({ + ids = [inv_1.id] + register_payments = self.register_payments_model.with_context(active_ids=ids).create({ 'payment_date': time.strftime('%Y') + '-07-15', 'journal_id': self.bank_journal_euro.id, 'payment_method_id': self.payment_method_manual_in.id, @@ -99,7 +101,6 @@ class TestPayment(AccountingTestCase): self.assertEqual(payment.state, 'posted') self.assertEqual(payment.state, 'posted') self.assertEqual(inv_1.state, 'paid') - self.assertEqual(inv_2.state, 'paid') self.assertRecordValues(payment.move_line_ids, [ {'account_id': self.account_eur.id, 'debit': 300.0, 'credit': 0.0, 'amount_currency': 0, 'currency_id': False}, @@ -108,7 +109,7 @@ class TestPayment(AccountingTestCase): self.assertTrue(payment.move_line_ids.filtered(lambda l: l.account_id == inv_1.account_id)[0].full_reconcile_id) liquidity_aml = payment.move_line_ids.filtered(lambda r: r.account_id == self.account_eur) - bank_statement = self.reconcile(liquidity_aml, 200, 0, False) + bank_statement = self.reconcile(liquidity_aml, 300, 0, False) self.assertEqual(liquidity_aml.statement_id, bank_statement) self.assertEqual(liquidity_aml.statement_line_id, bank_statement.line_ids[0]) @@ -163,20 +164,21 @@ class TestPayment(AccountingTestCase): # One payment for inv_4 (Vendor Bill) inv_4 = self.create_invoice(amount=50, partner=self.partner_agrolait.id, type='in_invoice') - ids = [inv_1.id, inv_2.id, inv_3.id, inv_4.id] - register_payments = self.register_payments_model.with_context(active_ids=ids).create({ - 'payment_date': time.strftime('%Y') + '-07-15', - 'journal_id': self.bank_journal_euro.id, - 'payment_method_id': self.payment_method_manual_in.id, - 'group_invoices': True, - }) - register_payments.create_payments() - payment_ids = self.payment_model.search([('invoice_ids', 'in', ids)], order="id desc") + ids_out = [inv_1.id, inv_2.id, inv_3.id] + ids_in = [inv_4.id] + for ids in [ids_out, ids_in]: + register_payments = self.register_payments_model.with_context(active_ids=ids).create({ + 'payment_date': time.strftime('%Y') + '-07-15', + 'journal_id': self.bank_journal_euro.id, + 'payment_method_id': self.payment_method_manual_in.id, + }) + register_payments.create_payments() + payment_ids = self.payment_model.search([('invoice_ids', 'in', ids_out + ids_in)], order="id desc") - self.assertEqual(len(payment_ids), 3) - self.assertAlmostEquals(register_payments.amount, 750) + self.assertEqual(len(payment_ids), 4) + self.assertAlmostEquals(sum(payment_ids.filtered(lambda r: r.payment_type == 'inbound').mapped('amount')) - sum(payment_ids.filtered(lambda r: r.payment_type == 'outbound').mapped('amount')), 750) - inv_1_2_pay = None + inv_1_2_pay = self.env['account.payment'] inv_3_pay = None inv_4_pay = None for payment_id in payment_ids: @@ -186,29 +188,24 @@ class TestPayment(AccountingTestCase): self.assertEqual(payment_id.amount, 50) inv_4_pay = payment_id else: - self.assertEqual(payment_id.amount, 600) - inv_1_2_pay = payment_id + self.assertTrue(payment_id.amount in (100, 500)) + inv_1_2_pay |= payment_id else: self.assertEqual(payment_id.amount, 200) inv_3_pay = payment_id - self.assertIsNotNone(inv_1_2_pay) - self.assertIsNotNone(inv_3_pay) - self.assertIsNotNone(inv_4_pay) - - self.assertRecordValues(inv_1_2_pay.move_line_ids, [ - {'account_id': self.account_eur.id, 'debit': 600.0, 'credit': 0.0, 'amount_currency': 0.0, 'currency_id': False}, - {'account_id': inv_1.account_id.id, 'debit': 0.0, 'credit': 600.0, 'amount_currency': 0.0, 'currency_id': False}, - ]) + self.assertTrue(len(inv_1_2_pay) == 2) self.assertEqual(inv_1.state, 'paid') self.assertEqual(inv_2.state, 'paid') + self.assertIsNotNone(inv_3_pay) self.assertRecordValues(inv_3_pay.move_line_ids, [ {'account_id': self.account_eur.id, 'debit': 200.0, 'credit': 0.0, 'amount_currency': 0.0, 'currency_id': False}, {'account_id': inv_1.account_id.id, 'debit': 0.0, 'credit': 200.0, 'amount_currency': 0.0, 'currency_id': False}, ]) self.assertEqual(inv_3.state, 'paid') + self.assertIsNotNone(inv_4_pay) self.assertRecordValues(inv_4_pay.move_line_ids, [ {'account_id': self.account_eur.id, 'debit': 0.0, 'credit': 50.0, 'amount_currency': 0.0, 'currency_id': False}, {'account_id': inv_1.account_id.id, 'debit': 50.0, 'credit': 0.0, 'amount_currency': 0.0, 'currency_id': False}, @@ -220,16 +217,15 @@ class TestPayment(AccountingTestCase): # Test Customer Invoice inv_1 = self.create_invoice(amount=600) ids = [inv_1.id] - register_payments = self.register_payments_model.with_context(active_ids=ids).create({ - 'payment_date': time.strftime('%Y') + '-07-15', - 'journal_id': self.bank_journal_euro.id, - 'payment_method_id': self.payment_method_manual_in.id, - }) + payment_register = Form(self.env['account.payment'].with_context(active_model='account.invoice', active_ids=ids)) + with payment_register as pr: + pr.payment_date = time.strftime('%Y') + '-07-15' + pr.journal_id = self.bank_journal_euro + pr.payment_method_id = self.payment_method_manual_in - # Perform the partial payment by setting the amount at 550 instead of 600 - register_payments.amount = 550 + # Perform the partial payment by setting the amount at 550 instead of 600 + pr.amount = 550 - register_payments.create_payments() payment_ids = self.payment_model.search([('invoice_ids', 'in', ids)], order="id desc") self.assertEqual(len(payment_ids), 1) @@ -245,16 +241,16 @@ class TestPayment(AccountingTestCase): # Test Vendor Bill inv_2 = self.create_invoice(amount=500, type='in_invoice', partner=self.partner_china_exp.id) ids = [inv_2.id] - register_payments = self.register_payments_model.with_context(active_ids=ids).create({ - 'payment_date': time.strftime('%Y') + '-07-15', - 'journal_id': self.bank_journal_euro.id, - 'payment_method_id': self.payment_method_manual_in.id, - }) + payment_register = Form(self.env['account.payment'].with_context(active_model='account.invoice', active_ids=ids)) + with payment_register as pr: + pr.payment_date = time.strftime('%Y') + '-07-15' + pr.journal_id = self.bank_journal_euro + pr.payment_method_id = self.payment_method_manual_in - # Perform the partial payment by setting the amount at 300 instead of 500 - register_payments.amount = 300 + # Perform the partial payment by setting the amount at 300 instead of 500 + pr.amount = 300 - register_payments.create_payments() + payment_register.save() payment_ids = self.payment_model.search([('invoice_ids', 'in', ids)], order="id desc") self.assertEqual(len(payment_ids), 1) @@ -267,91 +263,6 @@ class TestPayment(AccountingTestCase): self.assertEqual(payment_id.partner_id, self.partner_china_exp) self.assertEqual(payment_id.partner_type, 'supplier') - def test_multiple_receivables(self): - """ Create test to pay several invoices with same partner but multiple receivable accounts """ - # One payment for inv_1, inv_2 (same receivable account) and inv_3 (another receivable account) - - account_receivable_id_1 = self.account_receivable.id - account_receivable_id_2 = self.account_receivable.copy(default={ - 'code': '%s (%s)' % (self.account_receivable.code, 'duplicate 1') - }).id - - inv_1 = self.create_invoice(amount=100, account_id=account_receivable_id_1) - inv_2 = self.create_invoice(amount=150, account_id=account_receivable_id_1) - inv_3 = self.create_invoice(amount=300, account_id=account_receivable_id_2) - - ids = [inv_1.id, inv_2.id, inv_3.id] - register_payments = self.register_payments_model.with_context(active_ids=ids).create({ - 'payment_date': time.strftime('%Y') + '-07-15', - 'journal_id': self.bank_journal_euro.id, - 'payment_method_id': self.payment_method_manual_in.id, - 'group_invoices': True, - }) - register_payments.create_payments() - payment_ids = self.payment_model.search([('invoice_ids', 'in', ids)], order="id desc") - - self.assertEqual(len(payment_ids), 2) - self.assertAlmostEquals(register_payments.amount, 550) - - inv_1_pay = payment_ids.filtered(lambda p: p.state == 'posted' and p.invoice_ids and p.invoice_ids == inv_1 + inv_2) - inv_2_pay = payment_ids.filtered(lambda p: p.state == 'posted' and p.invoice_ids and p.invoice_ids == inv_3) - - self.assertTrue(inv_1_pay) - self.assertTrue(inv_2_pay) - - self.assertRecordValues(inv_1_pay.move_line_ids, [ - {'account_id': self.account_eur.id, 'debit': 250.0, 'credit': 0.0, 'amount_currency': 0.0, 'currency_id': False}, - {'account_id': inv_1.account_id.id, 'debit': 0.0, 'credit': 250.0, 'amount_currency': 0.0, 'currency_id': False}, - ]) - - self.assertRecordValues(inv_2_pay.move_line_ids, [ - {'account_id': self.account_eur.id, 'debit': 300.0, 'credit': 0.0, 'amount_currency': 0.0, 'currency_id': False}, - {'account_id': inv_3.account_id.id, 'debit': 0.0, 'credit': 300.0, 'amount_currency': 0.0, 'currency_id': False}, - ]) - - def test_register_payment_group_invoices(self): - """ Tests the 'group invoices' option of the payment registering wizard - """ - - account_receivable_id_1 = self.account_receivable.id - account_receivable_id_2 = self.account_receivable.copy(default={ - 'code': '%s (%s)' % (self.account_receivable.code, 'duplicate 1') - }).id - - inv_1 = self.create_invoice(amount=100, account_id=account_receivable_id_1) - inv_2 = self.create_invoice(amount=150, account_id=account_receivable_id_1) - inv_3 = self.create_invoice(amount=300, account_id=account_receivable_id_2) - inv_4 = self.create_invoice(amount=300, account_id=account_receivable_id_2, partner=self.partner_china_exp.id) - - inv_5 = self.create_invoice(amount=42, account_id=account_receivable_id_1) - inv_6 = self.create_invoice(amount=1111, account_id=account_receivable_id_1) - inv_7 = self.create_invoice(amount=666, account_id=account_receivable_id_2) - inv_8 = self.create_invoice(amount=300, account_id=account_receivable_id_2, partner=self.partner_china_exp.id) - - # When grouping invoices, we should have one payment per receivable account - ids1 = [inv_1.id, inv_2.id, inv_3.id, inv_4.id] - register_payments1 = self.register_payments_model.with_context(active_ids=ids1).create({ - 'payment_date': time.strftime('%Y') + '-07-15', - 'journal_id': self.bank_journal_euro.id, - 'payment_method_id': self.payment_method_manual_in.id, - 'group_invoices': True, - }) - register_payments1.create_payments() - payment_ids1 = self.payment_model.search([('invoice_ids', 'in', ids1)], order="id desc") - self.assertEqual(len(payment_ids1), 3, "3 payments should have been created, one fo each (partner, receivable account).") - - # When not grouping, we should have one payment per invoice - ids2 = [inv_5.id, inv_6.id, inv_7.id, inv_8.id] - register_payments2 = self.register_payments_model.with_context(active_ids=ids2).create({ - 'payment_date': time.strftime('%Y') + '-07-15', - 'journal_id': self.bank_journal_euro.id, - 'payment_method_id': self.payment_method_manual_in.id, - 'group_invoices': False, - }) - register_payments2.create_payments() - payment_ids2 = self.payment_model.search([('invoice_ids', 'in', ids2)], order="id desc") - self.assertEqual(len(payment_ids2), 4, "Not grouping payments should always create a distinct payment per invoice.") - def test_payment_and_writeoff_in_other_currency_1(self): # Use case: # Company is in EUR, create a customer invoice for 25 EUR and register payment of 25 USD. @@ -595,37 +506,3 @@ class TestPayment(AccountingTestCase): # The invoice should now be paid self.assertEqual(invoice.state, 'paid', "Invoice should be in 'paid' state after having reconciled the two payments with a bank statement") - - def test_register_payments_multi_invoices(self): - inv1 = self.create_invoice(amount=40) - inv2 = inv1.copy() - inv2.action_invoice_open() - - batch_payment = self.env['account.register.payments'].with_context(active_ids=(inv1 + inv2).ids).create({ - 'amount': 70, - 'partner_id': inv1.partner_id.id, - 'journal_id': self.bank_journal_usd.id, - 'invoice_ids': [(6, False, (inv1 + inv2).ids)], - 'partner_type': 'customer', - 'payment_difference_handling': 'reconcile', - 'payment_type': 'inbound', - 'payment_method_id': self.payment_method_manual_in.id, - 'writeoff_account_id': self.account_revenue.id, - 'writeoff_label': "why can't we live together"}) - - payment_id = batch_payment.create_payments()['res_id'] - payment_id = self.env['account.payment'].browse(payment_id) - - self.assertEqual(inv1.state, 'paid') - self.assertEqual(inv2.state, 'paid') - - receivable_inv1 = inv1.move_id.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') - receivable_inv2 = inv2.move_id.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') - receivable_payment = payment_id.move_line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') - - self.assertEqual( - receivable_inv1.full_reconcile_id.reconciled_line_ids, - receivable_inv1 + receivable_inv2 + receivable_payment - ) - write_off_line = payment_id.move_line_ids.filtered(lambda l: l.account_id == payment_id.writeoff_account_id) - self.assertEqual(write_off_line.debit, 10) diff --git a/addons/account/tests/test_reconciliation.py b/addons/account/tests/test_reconciliation.py index 49a9be865e2..0fc4c2bc7dd 100644 --- a/addons/account/tests/test_reconciliation.py +++ b/addons/account/tests/test_reconciliation.py @@ -6,7 +6,8 @@ import time import unittest -@tagged('post_install', '-at_install') +# TODO in master +# The name of this class should be TestReconciliationHelpers class TestReconciliation(AccountingTestCase): """Tests for reconciliation (account.tax) @@ -167,6 +168,10 @@ class TestReconciliation(AccountingTestCase): supplier_move_lines = bank_stmt.move_line_ids return customer_move_lines, supplier_move_lines + +@tagged('post_install', '-at_install') +class TestReconciliationExec(TestReconciliation): + def test_statement_usd_invoice_eur_transaction_eur(self): customer_move_lines, supplier_move_lines = self.make_customer_and_supplier_flows(self.currency_euro_id, 30, self.bank_journal_usd, 42, 30, self.currency_euro_id) self.assertRecordValues(customer_move_lines, [ @@ -1911,3 +1916,121 @@ class TestReconciliation(AccountingTestCase): (move_lines - base_amount_tax_lines) .filtered(lambda l: l.account_id == self.tax_final_account) .debit, 17094.66) + + def test_reconciliation_cash_basis_revert(self): + company = self.env.ref('base.main_company') + company.tax_cash_basis_journal_id = self.cash_basis_journal + tax_cash_basis10percent = self.tax_cash_basis.copy({'amount': 10}) + self.tax_waiting_account.reconcile = True + tax_waiting_account10 = self.tax_waiting_account.copy({ + 'name': 'TAX WAIT 10', + 'code': 'TWAIT1', + }) + + AccountMoveLine = self.env['account.move.line'].with_context(check_move_validity=False) + + # Purchase + purchase_move = self.env['account.move'].create({ + 'name': 'invoice', + 'journal_id': self.purchase_journal.id, + }) + + purchase_payable_line0 = AccountMoveLine.create({ + 'account_id': self.account_rsa.id, + 'credit': 175, + 'move_id': purchase_move.id, + }) + + AccountMoveLine.create({ + 'name': 'expenseTaxed 10%', + 'account_id': self.expense_account.id, + 'debit': 50, + 'move_id': purchase_move.id, + 'tax_ids': [(4, tax_cash_basis10percent.id, False)], + }) + tax_line0 = AccountMoveLine.create({ + 'name': 'TaxLine0', + 'account_id': tax_waiting_account10.id, + 'debit': 5, + 'move_id': purchase_move.id, + 'tax_line_id': tax_cash_basis10percent.id, + }) + AccountMoveLine.create({ + 'name': 'expenseTaxed 20%', + 'account_id': self.expense_account.id, + 'debit': 100, + 'move_id': purchase_move.id, + 'tax_ids': [(4, self.tax_cash_basis.id, False)], + }) + tax_line1 = AccountMoveLine.create({ + 'name': 'TaxLine1', + 'account_id': self.tax_waiting_account.id, + 'debit': 20, + 'move_id': purchase_move.id, + 'tax_line_id': self.tax_cash_basis.id, + }) + purchase_move.post() + + reverted = self.env['account.move'].browse(purchase_move.reverse_moves()) + self.assertTrue(reverted.exists()) + + for inv_line in [purchase_payable_line0, tax_line0, tax_line1]: + self.assertTrue(inv_line.full_reconcile_id.exists()) + reverted_expected = reverted.line_ids.filtered(lambda l: l.account_id == inv_line.account_id) + self.assertEqual(len(reverted_expected), 1) + self.assertEqual(reverted_expected.full_reconcile_id, inv_line.full_reconcile_id) + + def test_reconciliation_cash_basis_foreign_currency_low_values(self): + journal = self.env['account.journal'].create({ + 'name': 'Bank', 'type': 'bank', 'code': 'THE', + 'currency_id': self.currency_usd_id, + }) + usd = self.env['res.currency'].browse(self.currency_usd_id) + usd.rate_ids.unlink() + self.env['res.currency.rate'].create({ + 'name': time.strftime('%Y-01-01'), + 'rate': 1/17.0, + 'currency_id': self.currency_usd_id, + 'company_id': self.env.ref('base.main_company').id, + }) + invoice = self.create_invoice( + type='out_invoice', invoice_amount=50, + currency_id=self.currency_usd_id) + invoice.journal_id.update_posted = True + invoice.action_cancel() + invoice.state = 'draft' + invoice.invoice_line_ids.write({ + 'invoice_line_tax_ids': [(6, 0, [self.tax_cash_basis.id])]}) + invoice.compute_taxes() + invoice.action_invoice_open() + + self.assertTrue(invoice.currency_id != self.env.user.company_id.currency_id) + + # First Payment + payment0 = self.make_payment(invoice, journal, invoice.amount_total - 0.01) + self.assertEqual(invoice.residual, 0.01) + + tax_waiting_line = invoice.move_id.line_ids.filtered(lambda l: l.account_id == self.tax_waiting_account) + self.assertFalse(tax_waiting_line.reconciled) + + move_caba0 = tax_waiting_line.matched_debit_ids.debit_move_id.move_id + self.assertTrue(move_caba0.exists()) + self.assertEqual(move_caba0.journal_id, self.env.user.company_id.tax_cash_basis_journal_id) + + pay_receivable_line0 = payment0.move_line_ids.filtered(lambda l: l.account_id == self.account_rcv) + self.assertTrue(pay_receivable_line0.reconciled) + self.assertEqual(pay_receivable_line0.matched_debit_ids, move_caba0.tax_cash_basis_rec_id) + + # Second Payment + payment1 = self.make_payment(invoice, journal, 0.01) + self.assertEqual(invoice.residual, 0) + self.assertEqual(invoice.state, 'paid') + + self.assertTrue(tax_waiting_line.reconciled) + move_caba1 = tax_waiting_line.matched_debit_ids.mapped('debit_move_id').mapped('move_id').filtered(lambda m: m != move_caba0) + self.assertEqual(len(move_caba1.exists()), 1) + self.assertEqual(move_caba1.journal_id, self.env.user.company_id.tax_cash_basis_journal_id) + + pay_receivable_line1 = payment1.move_line_ids.filtered(lambda l: l.account_id == self.account_rcv) + self.assertTrue(pay_receivable_line1.reconciled) + self.assertEqual(pay_receivable_line1.matched_debit_ids, move_caba1.tax_cash_basis_rec_id) diff --git a/addons/account/views/account_invoice_view.xml b/addons/account/views/account_invoice_view.xml index 0e62eabcf8b..44c8caf9a85 100644 --- a/addons/account/views/account_invoice_view.xml +++ b/addons/account/views/account_invoice_view.xml @@ -153,7 +153,7 @@ <field name="name">account.invoice.tree</field> <field name="model">account.invoice</field> <field name="arch" type="xml"> - <tree decoration-info="state == 'draft'" decoration-muted="state == 'cancel'" string="Invoice"> + <tree decoration-info="state == 'draft'" decoration-muted="state == 'cancel'" string="Invoice" js_class="account_tree"> <field name="partner_id" groups="base.group_user" string="Customer"/> <field name="date_invoice"/> <field name="number"/> @@ -233,7 +233,7 @@ <field name="name">account.invoice.supplier.tree</field> <field name="model">account.invoice</field> <field name="arch" type="xml"> - <tree decoration-info="state == 'draft'" decoration-muted="state == 'cancel'" decoration-bf="not partner_id" string="Vendor Bill" js_class="account_bills_tree"> + <tree decoration-info="state == 'draft'" decoration-muted="state == 'cancel'" decoration-bf="not partner_id" string="Vendor Bill" js_class="account_tree"> <field name="partner_id" invisible="1"/> <field name="source_email" invisible="1"/> <field name="invoice_icon" string=" "/> @@ -267,7 +267,7 @@ <form string="Vendor Bill"> <header> <button name="action_invoice_open" type="object" states="draft" string="Validate" class="oe_highlight" groups="account.group_account_invoice"/> - <button name="%(action_account_invoice_payment)d" type="action" states="open" string="Register Payment" groups="account.group_account_invoice" class="oe_highlight"/> + <button name="action_register_payment" type="object" states="open" string="Register Payment" groups="account.group_account_invoice" class="oe_highlight"/> <button name="%(action_account_invoice_refund)d" type='action' string='Ask for a Credit Note' groups="account.group_account_invoice" attrs="{'invisible': ['|',('type', 'in', ['in_refund','out_refund']),('state','not in',('open','in_payment','paid'))]}"/> <button name="action_invoice_draft" states="cancel" string="Set to Draft" type="object" groups="account.group_account_invoice"/> <field name="state" widget="statusbar" statusbar_visible="draft,open,paid" /> @@ -402,7 +402,7 @@ </page> </notebook> </sheet> - <div class="o_attachment_preview" attrs="{'invisible': ['|',('type', '!=', 'in_invoice'),('state', '!=', 'draft')]}" /> + <div class="o_attachment_preview"/> <div class="oe_chatter"> <field name="message_follower_ids" widget="mail_followers"/> <field name="activity_ids" widget="mail_activity"/> @@ -420,7 +420,7 @@ <header> <button name="action_invoice_sent" string="Send & Print" type="object" attrs="{'invisible':['|',('sent','=',True), ('state', 'not in', ('open','in_payment','paid'))]}" class="oe_highlight" groups="base.group_user"/> <button name="action_invoice_sent" string="Send & Print" type="object" attrs="{'invisible':['|',('sent','=',False), ('state', 'not in', ('open','in_payment','paid'))]}" groups="base.group_user"/> - <button name="%(action_account_invoice_payment)d" id="account_invoice_payment_btn" type="action" + <button name="action_register_payment" id="account_invoice_payment_btn" type="object" attrs="{'invisible': [('state', '!=', 'open')]}" string="Register Payment" groups="account.group_account_invoice" class="oe_highlight"/> <button name="action_invoice_open" type="object" states="draft" string="Validate" class="oe_highlight o_invoice_validate" groups="account.group_account_invoice"/> @@ -460,7 +460,7 @@ widget="res_partner_many2one" context="{'search_default_customer':1, 'show_address': 1, 'default_is_company': True, 'show_vat': True}" options='{"always_reload": True, "no_quick_create": True}' - domain="[('customer', '=', True)]" required="1"/> + domain="[('customer', '=', True)]"/> <field name="payment_term_id"/> <field name="cash_rounding_id" groups="account.group_cash_rounding"/> </group> @@ -632,6 +632,7 @@ </page> </notebook> </sheet> + <div class="o_attachment_preview"/> <div class="oe_chatter"> <field name="message_follower_ids" widget="mail_followers" groups="base.group_user"/> <field name="activity_ids" widget="mail_activity"/> diff --git a/addons/account/views/account_journal_dashboard_view.xml b/addons/account/views/account_journal_dashboard_view.xml index 4c36bbada8d..354f33fd15f 100644 --- a/addons/account/views/account_journal_dashboard_view.xml +++ b/addons/account/views/account_journal_dashboard_view.xml @@ -291,9 +291,6 @@ </button> </t> <t t-if="journal_type == 'purchase'"> - <button type="object" name="action_create_new" class="btn btn-primary"> - <span>New Bill</span> - </button> <button type="action" name="%(account_invoice_import_wizard_action)d" class="btn btn-primary"> <span>Upload Bills</span> </button> diff --git a/addons/account/views/account_move_views.xml b/addons/account/views/account_move_views.xml index 381fa39ae2f..03fba65b684 100644 --- a/addons/account/views/account_move_views.xml +++ b/addons/account/views/account_move_views.xml @@ -36,6 +36,7 @@ <field name="invoice_id" attrs="{'invisible': [('invoice_id','=',False)]}"/> </group> <group string="Dates"> + <field name="date"/> <field name="date_maturity"/> </group> @@ -43,7 +44,7 @@ <field name="tax_line_id" readonly="1" attrs="{'invisible': [('tax_line_id','=',False)]}"/> <field name="tax_ids" widget="many2many_tags" readonly="1" attrs="{'invisible': [('tax_ids','=',[])]}"/> </group> - <group string="Matching" attrs="{'invisible':[('full_reconcile_id','=',False)]}"> + <group string="Matching" attrs="{'invisible':[('full_reconcile_id','=',False), ('matched_debit_ids', '=', []),('matched_credit_ids', '=', [])]}"> <label for="full_reconcile_id"/> <div> <field name="full_reconcile_id"/> diff --git a/addons/account/views/account_payment_view.xml b/addons/account/views/account_payment_view.xml index 40755b20e45..772a29affff 100644 --- a/addons/account/views/account_payment_view.xml +++ b/addons/account/views/account_payment_view.xml @@ -24,19 +24,12 @@ <record id="view_account_supplier_payment_tree" model="ir.ui.view"> <field name="name">account.supplier.payment.tree</field> <field name="model">account.payment</field> + <field name="inherit_id" ref="account.view_account_payment_tree"/> + <field name="mode">primary</field> <field name="arch" type="xml"> - <tree decoration-info="state == 'draft'" decoration-muted="state in ['reconciled', 'cancelled']" edit="false"> - <field name="payment_date"/> - <field name="name"/> - <field name="journal_id"/> - <field name="payment_method_id"/> - <field name="partner_id" string="Vendor"/> - <field name="amount" sum="Amount"/> - <field name="company_id" groups="base.group_multi_company"/> - <field name="state"/> - <field name="currency_id" invisible="1"/> - <field name="partner_type" invisible="1"/> - </tree> + <field name="partner_id" position="attributes"> + <attribute name="string">Vendor</attribute> + </field> </field> </record> @@ -150,34 +143,73 @@ <h1><field name="name"/></h1> </div> <group> + <field name="invoice_ids" invisible="1"/> + <group name="partner_group" invisible="context.get('active_model') == 'account.invoice'"> + <field name="payment_type" widget="radio"/> + <field name="partner_type" widget="selection" attrs="{'required': [('state', '=', 'draft'), ('payment_type', 'in', ('inbound', 'outbound'))], 'invisible': [('payment_type', 'not in', ('inbound', 'outbound'))]}"/> + <field name="partner_id" attrs="{'required': [('state', '=', 'draft'), ('payment_type', 'in', ('inbound', 'outbound'))], 'invisible': [('payment_type', 'not in', ('inbound', 'outbound'))]}" context="{'default_is_company': True, 'default_supplier': payment_type == 'outbound', 'default_customer': payment_type == 'inbound'}"/> + </group> + <group name="right_column"> + <field name="communication"/> + <field name="payment_date"/> + </group> <group> - <field name="payment_type" widget="radio" attrs="{'readonly': [('state', '!=', 'draft')]}"/> - <field name="partner_type" widget="selection" attrs="{'required': [('state', '=', 'draft'), ('payment_type', 'in', ('inbound', 'outbound'))], 'invisible': [('payment_type', 'not in', ('inbound', 'outbound'))], 'readonly': [('state', '!=', 'draft')]}"/> - <field name="partner_id" attrs="{'required': [('state', '=', 'draft'), ('payment_type', 'in', ('inbound', 'outbound'))], 'invisible': [('payment_type', 'not in', ('inbound', 'outbound'))], 'readonly': [('state', '!=', 'draft')]}" context="{'default_is_company': True, 'default_supplier': payment_type == 'outbound', 'default_customer': payment_type == 'inbound'}"/> <label for="amount"/> <div name="amount_div" class="o_row"> - <field name="amount" attrs="{'readonly': [('state', '!=', 'draft')]}"/> - <field name="currency_id" options="{'no_create': True, 'no_open': True}" groups="base.group_multi_currency" attrs="{'readonly': [('state', '!=', 'draft')]}"/> + <field name="amount"/> + <field name="currency_id" options="{'no_create': True, 'no_open': True}" groups="base.group_multi_currency"/> </div> - <field name="journal_id" widget="selection" attrs="{'readonly': [('state', '!=', 'draft')]}"/> - <field name="destination_journal_id" widget="selection" attrs="{'required': [('payment_type', '=', 'transfer')], 'invisible': [('payment_type', '!=', 'transfer')], 'readonly': [('state', '!=', 'draft')]}"/> + <field name="journal_id" widget="selection" attrs="{'invisible': [('amount', '=', 0), ('state', '!=', 'draft')]}"/> + <field name="destination_journal_id" widget="selection" attrs="{'required': [('payment_type', '=', 'transfer')], 'invisible': [('payment_type', '!=', 'transfer')]}"/> <field name="hide_payment_method" invisible="1"/> - <field name="payment_method_id" string=" " widget="radio" attrs="{'invisible': [('hide_payment_method', '=', True)], 'readonly': [('state', '!=', 'draft')]}"/> - <field name="partner_bank_account_id" attrs="{'invisible': [('show_partner_bank_account', '!=', True)], 'required': [('show_partner_bank_account', '=', True)], 'readonly': [('state', '!=', 'draft')]}" context="{'default_partner_id': partner_id}"/> + <field name="payment_method_id" widget="radio" attrs="{'invisible': [('hide_payment_method', '=', True)]}"/> + <field name="partner_bank_account_id" attrs="{'invisible': [('show_partner_bank_account', '!=', True)], 'required': [('require_partner_bank_account', '=', True)]}" context="{'default_partner_id': partner_id}"/> <field name="show_partner_bank_account" invisible="1"/> + <field name="require_partner_bank_account" invisible="1"/> <field name="payment_method_code" invisible="1"/> </group> - <group> - <field name="payment_date" attrs="{'readonly': [('state', '!=', 'draft')]}"/> - <field name="communication" attrs="{'invisible': [('state', '!=', 'draft'), ('communication', '=', False)], 'readonly': [('state', '!=', 'draft')]}"/> + <group attrs="{'invisible': [('payment_difference', '=', 0.0)]}"> + <label for="payment_difference"/> + <div> + <field name="payment_difference"/> + <field name="payment_difference_handling" widget="radio" nolabel="1"/> + <div attrs="{'invisible': [('payment_difference_handling','=','open')]}"> + <label for="writeoff_account_id" class="oe_edit_only" string="Post Difference In"/> + <field name="writeoff_account_id" options="{'no_create': True}" string="Post Difference In" attrs="{'required': [('payment_difference_handling', '=', 'reconcile')]}"/> + <label for="writeoff_label" class="oe_edit_only" string="Label"/> + <field name="writeoff_label" attrs="{'required': [('payment_difference_handling', '=', 'reconcile')]}"/> + </div> + </div> </group> </group> </sheet> <div class="oe_chatter"> - <field name="message_follower_ids" widget="mail_followers" groups="base.group_user"/> - <field name="activity_ids" widget="mail_activity"/> - <field name="message_ids" widget="mail_thread"/> - </div> + <field name="message_follower_ids" widget="mail_followers" groups="base.group_user"/> + <field name="activity_ids" widget="mail_activity"/> + <field name="message_ids" widget="mail_thread"/> + </div> + </form> + </field> + </record> + + <record id="view_account_payment_form_multi" model="ir.ui.view"> + <field name="name">account.payment.form.multi</field> + <field name="model">account.payment.register</field> + <field name="arch" type="xml"> + <form string="Register Payment"> + <group> + <group> + <field name="payment_method_id" widget="radio"/> + </group> + <group> + <field name="journal_id" widget="selection"/> + <field name="payment_date"/> + </group> + </group> + <footer> + <button string="Create Payment" name="create_payments" type="object" class="oe_highlight"/> + <button string="Cancel" class="btn btn-secondary" special="cancel"/> + </footer> </form> </field> </record> @@ -251,139 +283,30 @@ <record id="view_account_payment_invoice_form" model="ir.ui.view"> <field name="name">account.payment.invoice.form</field> <field name="model">account.payment</field> + <field name="inherit_id" ref="view_account_payment_form"/> + <field name="mode">primary</field> <field name="arch" type="xml"> - <form string="Register Payment"> - <sheet> - <group> - <field name="payment_type" invisible="1"/> - <field name="partner_type" invisible="1"/> - <field name="invoice_ids" invisible="1"/> - <field name="partner_id" invisible="1"/> - <field name="state" invisible="1"/> - <group> - <label for="amount"/> - <div name="amount_div" class="o_row"> - <field name="amount"/> - <field name="currency_id" options="{'no_create': True, 'no_open': True}" groups="base.group_multi_currency"/> - </div> - <field name="journal_id" widget="selection" attrs="{'invisible': [('amount', '=', 0)]}"/> - <field name="hide_payment_method" invisible="1"/> - <field name="payment_method_id" widget="radio" attrs="{'invisible': ['|', ('hide_payment_method', '=', True), ('amount', '=', 0.0)]}"/> - <field name="partner_bank_account_id" attrs="{'invisible': [('show_partner_bank_account', '!=', True)], 'required': [('show_partner_bank_account', '=', True)], 'readonly': [('state', '!=', 'draft')]}" context="{'default_partner_id': partner_id}"/> - <field name="show_partner_bank_account" invisible="1"/> - <field name="payment_method_code" invisible="1"/> - </group> - <group> - <field name="payment_date"/> - <field name="communication"/> - </group> - <group attrs="{'invisible': [('payment_difference', '=', 0.0)]}"> - <label for="payment_difference"/> - <div> - <field name="payment_difference"/> - <field name="payment_difference_handling" widget="radio" nolabel="1"/> - <div attrs="{'invisible': [('payment_difference_handling','=','open')]}"> - <label for="writeoff_account_id" class="oe_edit_only" string="Post Difference In"/> - <field name="writeoff_account_id" options="{'no_create': True}" string="Post Difference In" attrs="{'required': [('payment_difference_handling', '=', 'reconcile')]}"/> - <label for="journal_id" string="Journal" attrs="{'invisible': [('amount', '!=', 0)]}"/> - <field name="journal_id" string="Journal" widget="selection" attrs="{'invisible': [('amount', '!=', 0)]}"/> - <label for="writeoff_label" class="oe_edit_only" string="Label"/> - <field name="writeoff_label" attrs="{'required': [('payment_difference_handling', '=', 'reconcile')]}"/> - </div> - </div> - </group> - </group> - </sheet> + <xpath expr="//header" position='replace'></xpath> + <xpath expr="//sheet" position='after'> <footer> - <button string='Validate' name="action_validate_invoice_payment" type="object" class="btn-primary"/> + <field name="state" invisible="1"/> + <button string='Validate' name="post" type="object" class="btn-primary"/> <button string="Cancel" class="btn-secondary" special="cancel"/> </footer> - </form> + </xpath> </field> </record> - <record id="action_account_invoice_payment" model="ir.actions.act_window"> + <record id="action_account_invoice_from_list" model="ir.actions.server"> <field name="name">Register Payment</field> - <field name="res_model">account.payment</field> - <field name="view_type">form</field> - <field name="view_mode">form</field> - <field name="view_id" ref="view_account_payment_invoice_form"/> - <field name="context">{'default_invoice_ids': [(4, active_id, None)]}</field> - <field name="target">new</field> - </record> - - - <!-- Register payment from several invoices --> - - <record id="view_account_payment_from_invoices" model="ir.ui.view"> - <field name="name">account.register.payments.wizard</field> - <field name="model">account.register.payments</field> - <field name="arch" type="xml"> - <form string="Register Payment"> - <group> - <field name="payment_type" invisible="1"/> - <field name="partner_type" invisible="1"/> - <field name="invoice_ids" invisible="1"/> - <field name="partner_id" invisible="1"/> - <field name="multi" invisible="1"/> - <group> - <label for="amount"/> - <div name="amount_div" class="o_row"> - <field name="amount" attrs="{'readonly': [('multi', '=', True)]}"/> - <field name="currency_id" - attrs="{'readonly': [('multi', '=', True)]}" - options="{'no_create': True, 'no_open': True}" - groups="base.group_multi_currency"/> - </div> - <field name="journal_id" widget="selection" attrs="{'invisible': [('amount', '=', 0)]}"/> - <field name="hide_payment_method" invisible="1"/> - <field name="payment_method_id" widget="radio" attrs="{'invisible': ['|', ('hide_payment_method', '=', True), ('amount', '=', 0.0)]}"/> - <field name="partner_bank_account_id" attrs="{'invisible': [('show_partner_bank_account', '!=', True)], 'required': [('show_partner_bank_account', '=', True)]}" context="{'default_partner_id': partner_id}"/> - <field name="show_partner_bank_account" invisible="1"/> - <field name="payment_method_code" invisible="1"/> - </group> - <group> - <field name="payment_date"/> - <field name="group_invoices"/> - <field name="show_communication_field" invisible="1"/> - <field name="communication" attrs="{'invisible': [('show_communication_field', '=', False)]}"/> - <p colspan="2" attrs="{'invisible': [('show_communication_field', '=', True)]}">Memo will be computed from invoices</p> - </group> - <group attrs="{'invisible': [('payment_difference', '=', 0.0)]}"> - <label for="payment_difference"/> - <div> - <field name="payment_difference"/> - <field name="payment_difference_handling" widget="radio" nolabel="1"/> - <div attrs="{'invisible': [('payment_difference_handling','=','open')]}"> - <label for="writeoff_account_id" class="oe_edit_only" string="Post Difference In"/> - <field name="writeoff_account_id" options="{'no_create': True}" string="Post Difference In" attrs="{'required': [('payment_difference_handling', '=', 'reconcile')]}"/> - <label for="journal_id" string="Journal" attrs="{'invisible': [('amount', '!=', 0)]}"/> - <field name="journal_id" string="Journal" widget="selection" attrs="{'invisible': [('amount', '!=', 0)]}"/> - <label for="writeoff_label" class="oe_edit_only" string="Label"/> - <field name="writeoff_label" attrs="{'required': [('payment_difference_handling', '=', 'reconcile')]}"/> - </div> - </div> - </group> - </group> - <footer> - <button string='Validate' name="create_payments" type="object" class="btn-primary"/> - <button string="Cancel" class="btn-secondary" special="cancel"/> - </footer> - </form> + <field name="model_id" ref="account.model_account_payment"/> + <field name="binding_model_id" ref="account.model_account_invoice"/> + <field name="state">code</field> + <field name="code"> + action = model.action_register_payment() </field> </record> - <act_window - id="action_account_payment_from_invoices" - name="Register Payment" - res_model="account.register.payments" - src_model="account.invoice" - view_mode="form" - multi="True" - target="new" - key2="client_action_multi" - /> - <!-- Action confirm_payments for multi --> <record id="action_account_confirm_payments" model="ir.actions.server"> <field name="name">Post Payments</field> @@ -392,7 +315,7 @@ <field name="model_id" ref="account.model_account_payment"/> <field name="binding_model_id" ref="account.model_account_payment"/> <field name="code"> -records.post() + records.post() </field> </record> diff --git a/addons/account/views/account_view.xml b/addons/account/views/account_view.xml index 941434cc8aa..5477247eebc 100644 --- a/addons/account/views/account_view.xml +++ b/addons/account/views/account_view.xml @@ -284,13 +284,13 @@ <field name="invoice_reference_type"/> <field name="invoice_reference_model" attrs="{'invisible': [('invoice_reference_type', '=', 'none')]}"/> </group> - <group string="Accounting App Options"> + <group string="Accounting App Options" attrs="{'invisible': [('type', 'not in', ['bank', 'cash', 'sale', 'purchase'])]}"> <field name="profit_account_id" options="{'no_create': True}" attrs="{'invisible': [('type', '!=', 'cash')]}"/> <field name="loss_account_id" options="{'no_create': True}" attrs="{'invisible': [('type', '!=', 'cash')]}"/> <field name="group_invoice_lines" attrs="{'invisible': [('type', 'not in', ['sale', 'purchase'])]}"/> <field name="post_at_bank_rec" attrs="{'invisible': [('type', 'not in', ['bank', 'cash'])]}"/> </group> - <group name="group_alias" string="Email your Vendor Bills" attrs="{'invisible': [('type', '!=', 'purchase')]}"> + <group name="group_alias" string="Email your Invoices/Bills" attrs="{'invisible': [('type', 'not in', ('sale' ,'purchase'))]}"> <label string="Email Alias" attrs="{'invisible': [('alias_domain', '=', False)]}" for="alias_id"/> <div name="alias_def" attrs="{'invisible': [('alias_domain', '=', False)]}"> <field name="alias_id" class="oe_read_only oe_inline"/> diff --git a/addons/account/views/report_invoice.xml b/addons/account/views/report_invoice.xml index 77aba90eaf2..a566bc80394 100644 --- a/addons/account/views/report_invoice.xml +++ b/addons/account/views/report_invoice.xml @@ -51,26 +51,31 @@ <table class="table table-sm o_main_table" name="invoice_line_table"> <thead> <tr> + <!-- TODO: remove in master --> + <t t-set="colspan" t-value="6"/> <th class="text-left"><span>Description</span></th> <th class="d-none text-left"><span>Source Document</span></th> <th class="text-right"><span>Quantity</span></th> <th t-attf-class="text-right {{ 'd-none d-md-table-cell' if report_type == 'html' else '' }}"><span>Unit Price</span></th> <th t-if="display_discount" t-attf-class="text-right {{ 'd-none d-md-table-cell' if report_type == 'html' else '' }}"> <span>Disc.%</span> + <!-- TODO: remove in master --> + <t t-set="colspan" t-value="colspan+1"/> </th> <th t-attf-class="text-left {{ 'd-none d-md-table-cell' if report_type == 'html' else '' }}"><span>Taxes</span></th> - <th class="text-right" groups="account.group_show_line_subtotals_tax_excluded">Amount</th> - <th class="text-right" groups="account.group_show_line_subtotals_tax_included">Total Price</th> + <th class="text-right"> + <span groups="account.group_show_line_subtotals_tax_excluded">Amount</span> + <span groups="account.group_show_line_subtotals_tax_included">Total Price</span> + </th> </tr> </thead> <tbody class="invoice_tbody"> - <t t-set="current_subtotal_tax_excluded" t-value="0"/> - <t t-set="current_subtotal_tax_included" t-value="0"/> + <t t-set="current_subtotal" t-value="0"/> <t t-foreach="o.invoice_line_ids" t-as="line"> - <t t-set="current_subtotal_tax_excluded" t-value="current_subtotal_tax_excluded + line.price_subtotal" groups="account.group_show_line_subtotals_tax_excluded"/> - <t t-set="current_subtotal_tax_included" t-value="current_subtotal_tax_included + line.price_total" groups="account.group_show_line_subtotals_tax_included"/> + <t t-set="current_subtotal" t-value="current_subtotal + line.price_subtotal" groups="account.group_show_line_subtotals_tax_excluded"/> + <t t-set="current_subtotal" t-value="current_subtotal + line.price_total" groups="account.group_show_line_subtotals_tax_included"/> <tr t-att-class="'bg-200 font-weight-bold o_line_section' if line.display_type == 'line_section' else 'font-italic o_line_note' if line.display_type == 'line_note' else ''"> <t t-if="not line.display_type" name="account_invoice_line_accountable"> @@ -89,11 +94,9 @@ <td t-attf-class="text-left {{ 'd-none d-md-table-cell' if report_type == 'html' else '' }}"> <span t-esc="', '.join(map(lambda x: (x.description or x.name), line.invoice_line_tax_ids))"/> </td> - <td class="text-right o_price_total" groups="account.group_show_line_subtotals_tax_excluded"> - <span t-field="line.price_subtotal"/> - </td> - <td class="text-right o_price_total" groups="account.group_show_line_subtotals_tax_included"> - <span t-field="line.price_total"/> + <td class="text-right o_price_total"> + <span t-field="line.price_subtotal" groups="account.group_show_line_subtotals_tax_excluded"/> + <span t-field="line.price_total" groups="account.group_show_line_subtotals_tax_included"/> </td> </t> <t t-if="line.display_type == 'line_section'"> @@ -101,8 +104,7 @@ <span t-field="line.name"/> </td> <t t-set="current_section" t-value="line"/> - <t t-set="current_subtotal_tax_excluded" t-value="0"/> - <t t-set="current_subtotal_tax_included" t-value="0"/> + <t t-set="current_subtotal" t-value="0"/> </t> <t t-if="line.display_type == 'line_note'"> <td colspan="99"> @@ -116,14 +118,9 @@ <td colspan="99"> <strong class="mr16">Subtotal</strong> <span - groups="account.group_show_line_subtotals_tax_excluded" - t-esc="current_subtotal_tax_excluded" + t-esc="current_subtotal" t-options='{"widget": "monetary", "display_currency": o.currency_id}' /> - <span groups="account.group_show_line_subtotals_tax_included"> | <span - t-esc="current_subtotal_tax_included" - t-options='{"widget": "monetary", "display_currency": o.currency_id}' - /></span> </td> </tr> </t> diff --git a/addons/account/views/report_payment_receipt_templates.xml b/addons/account/views/report_payment_receipt_templates.xml index 626cff36fe2..871ae39479f 100644 --- a/addons/account/views/report_payment_receipt_templates.xml +++ b/addons/account/views/report_payment_receipt_templates.xml @@ -42,7 +42,7 @@ </tr> </thead> <tbody> - <tr t-foreach="o.invoice_ids" t-as="inv"> + <tr t-foreach="o.reconciled_invoice_ids" t-as="inv"> <td><span t-field="inv.date_invoice"/></td> <td><span t-field="inv.number"/></td> <td class="text-right"><span t-field="inv.amount_total"/></td> diff --git a/addons/account/wizard/__init__.py b/addons/account/wizard/__init__.py index 0e5b3bf4df4..23355c658db 100644 --- a/addons/account/wizard/__init__.py +++ b/addons/account/wizard/__init__.py @@ -3,6 +3,7 @@ from . import account_unreconcile +from . import account_invoice_import from . import account_invoice_refund from . import account_invoice_state from . import account_validate_account_move diff --git a/addons/account/models/account_invoice_import_wizard.py b/addons/account/wizard/account_invoice_import.py index 0e401842287..75191634ebd 100644 --- a/addons/account/models/account_invoice_import_wizard.py +++ b/addons/account/wizard/account_invoice_import.py @@ -9,14 +9,14 @@ class ImportInvoiceImportWizard(models.TransientModel): _description = 'Import Your Vendor Bills from Files.' def _get_default_journal_id(self): - return self.env['account.journal'].search([('type', '=', 'purchase')], limit=1) + return self.env['account.journal'].search([('type', '=', self.env.context.get('journal_type'))], limit=1) attachment_ids = fields.Many2many('ir.attachment', string='Files') - journal_id = fields.Many2one(string="Journal", comodel_name="account.journal", required=True, domain="[('type', '=', 'purchase')]", default=_get_default_journal_id, help="Journal where to generate the bills") + journal_id = fields.Many2one(string="Journal", comodel_name="account.journal", required=True, domain="[('type', 'in', ('sale', 'purchase'))]", default=_get_default_journal_id, help="Journal where to generate the bills") @api.multi def _create_invoice_from_file(self, attachment): - self = self.with_context(default_journal_id= self.journal_id.id) + self = self.with_context(default_journal_id=self.journal_id.id) invoice_form = Form(self.env['account.invoice'], view='account.invoice_supplier_form') invoice = invoice_form.save() attachment.write({'res_model': 'account.invoice', 'res_id': invoice.id}) @@ -24,6 +24,10 @@ class ImportInvoiceImportWizard(models.TransientModel): return invoice @api.multi + def _create_invoice(self, attachment): + return self._create_invoice_from_file(attachment) + + @api.multi def create_invoices(self): ''' Create the invoices from files. :return: A action redirecting to account.invoice tree/form view. @@ -33,14 +37,16 @@ class ImportInvoiceImportWizard(models.TransientModel): invoices = self.env['account.invoice'] for attachment in self.attachment_ids: - invoices += self._create_invoice_from_file(attachment) + invoices += self._create_invoice(attachment) + form_view = self.env.context.get('journal_type') == 'purchase' and self.env.ref('account.invoice_supplier_form').id or self.env.ref('account.invoice_form').id + tree_view = self.env.context.get('journal_type') == 'purchase' and self.env.ref('account.invoice_supplier_tree').id or self.env.ref('account.invoice_tree').id action_vals = { - 'name': _('Invoices'), + 'name': _('Generated Documents'), 'domain': [('id', 'in', invoices.ids)], 'view_type': 'form', 'res_model': 'account.invoice', - 'view_id': False, + 'views': [[tree_view, "tree"], [form_view, "form"]], 'type': 'ir.actions.act_window', } if len(invoices) == 1: diff --git a/addons/account/views/account_invoice_import_wizard_view.xml b/addons/account/wizard/account_invoice_import_view.xml index b2238c88259..b60dd290f31 100644 --- a/addons/account/views/account_invoice_import_wizard_view.xml +++ b/addons/account/wizard/account_invoice_import_view.xml @@ -7,17 +7,12 @@ <field name="arch" type="xml"> <form> <group> - <group> - <field name="journal_id" context="{'default_type': 'purchase'}"/> - </group> - </group> - <group> <field name="attachment_ids" widget="many2many_binary" colspan="2" string="Upload Files" nolabel="1"/> </group> <footer> <button string="Import" name="create_invoices" type="object" class="oe_highlight"/> - <button string="Cancel" class="btn btn-default" special="cancel"/> + <button string="Cancel" class="btn btn-secondary" special="cancel"/> </footer> </form> </field> diff --git a/addons/account_check_printing/models/account_payment.py b/addons/account_check_printing/models/account_payment.py index d2fd855ab07..69bec9ea956 100644 --- a/addons/account_check_printing/models/account_payment.py +++ b/addons/account_check_printing/models/account_payment.py @@ -7,34 +7,15 @@ from odoo.tools.misc import formatLang, format_date INV_LINES_PER_STUB = 9 -class AccountRegisterPayments(models.TransientModel): - _inherit = "account.register.payments" - - check_amount_in_words = fields.Char(string="Amount in Words") - check_manual_sequencing = fields.Boolean(related='journal_id.check_manual_sequencing', readonly=1) - # Note: a check_number == 0 means that it will be attributed when the check is printed - check_number = fields.Char(string="Check Number", readonly=True, copy=False, default=0, - help="Number of the check corresponding to this payment. If your pre-printed check are not already numbered, " - "you can manage the numbering in the journal configuration page.") - - @api.onchange('journal_id') - def _onchange_journal_id(self): - if hasattr(super(AccountRegisterPayments, self), '_onchange_journal_id'): - super(AccountRegisterPayments, self)._onchange_journal_id() - if self.journal_id.check_manual_sequencing: - self.check_number = self.journal_id.check_sequence_id.number_next_actual - - @api.onchange('amount') - def _onchange_amount(self): - if hasattr(super(AccountRegisterPayments, self), '_onchange_amount'): - super(AccountRegisterPayments, self)._onchange_amount() - self.check_amount_in_words = self.currency_id.amount_to_text(self.amount) if self.currency_id else False +class AccountPaymentRegister(models.TransientModel): + _inherit = "account.payment.register" def _prepare_payment_vals(self, invoices): - res = super(AccountRegisterPayments, self)._prepare_payment_vals(invoices) + res = super(AccountPaymentRegister, self)._prepare_payment_vals(invoices) if self.payment_method_id == self.env.ref('account_check_printing.account_payment_method_check'): + currency_id = self.env['res.currency'].browse(res['currency_id']) res.update({ - 'check_amount_in_words': self.currency_id.amount_to_text(res['amount']) if self.multi else self.check_amount_in_words, + 'check_amount_in_words': currency_id.amount_to_text(res['amount']), }) return res @@ -48,12 +29,19 @@ class AccountPayment(models.Model): help="The selected journal is configured to print check numbers. If your pre-printed check paper already has numbers " "or if the current numbering is wrong, you can change it in the journal configuration page.") - @api.onchange('amount','currency_id') + @api.onchange('amount', 'currency_id') def _onchange_amount(self): res = super(AccountPayment, self)._onchange_amount() self.check_amount_in_words = self.currency_id.amount_to_text(self.amount) if self.currency_id else '' return res + @api.onchange('journal_id') + def _onchange_journal_id(self): + if hasattr(super(AccountPayment, self), '_onchange_journal_id'): + super(AccountPayment, self)._onchange_journal_id() + if self.journal_id.check_manual_sequencing: + self.check_number = self.journal_id.check_sequence_id.number_next_actual + def _check_communication(self, payment_method_id, communication): super(AccountPayment, self)._check_communication(payment_method_id, communication) if payment_method_id == self.env.ref('account_check_printing.account_payment_method_check').id: @@ -90,7 +78,7 @@ class AccountPayment(models.Model): ('journal_id', '=', self[0].journal_id.id), ('check_number', '!=', "0")], order="check_number desc", limit=1) next_check_number = last_printed_check and int(last_printed_check.check_number) + 1 or 1 - + return { 'name': _('Print Pre-numbered Checks'), 'type': 'ir.actions.act_window', @@ -152,12 +140,12 @@ class AccountPayment(models.Model): """ The stub is the summary of paid invoices. It may spill on several pages, in which case only the check on first page is valid. This function returns a list of stub lines per page. """ - if len(self.invoice_ids) == 0: + if len(self.reconciled_invoice_ids) == 0: return None multi_stub = self.company_id.account_check_printing_multi_stub - invoices = self.invoice_ids.sorted(key=lambda r: r.date_due) + invoices = self.reconciled_invoice_ids.sorted(key=lambda r: r.date_due) debits = invoices.filtered(lambda r: r.type == 'in_invoice') credits = invoices.filtered(lambda r: r.type == 'in_refund') diff --git a/addons/account_check_printing/tests/__init__.py b/addons/account_check_printing/tests/__init__.py new file mode 100644 index 00000000000..e7cb6f95910 --- /dev/null +++ b/addons/account_check_printing/tests/__init__.py @@ -0,0 +1 @@ +from . import test_print_check diff --git a/addons/account_check_printing/tests/test_print_check.py b/addons/account_check_printing/tests/test_print_check.py index 386ca39589c..a14a168d9a4 100644 --- a/addons/account_check_printing/tests/test_print_check.py +++ b/addons/account_check_printing/tests/test_print_check.py @@ -2,13 +2,14 @@ from __future__ import division from odoo.addons.account.tests.account_test_classes import AccountingTestCase -from odoo.addons.l10n_us_check_printing.report import print_check +from odoo.addons.account_check_printing.models.account_payment import INV_LINES_PER_STUB from odoo.tests import tagged +from odoo.tests.common import Form import time - import math + @tagged('post_install', '-at_install') class TestPrintCheck(AccountingTestCase): @@ -17,7 +18,7 @@ class TestPrintCheck(AccountingTestCase): self.invoice_model = self.env['account.invoice'] self.invoice_line_model = self.env['account.invoice.line'] - self.register_payments_model = self.env['account.register.payments'] + self.payment_model = self.env['account.payment'] self.partner_axelor = self.env.ref("base.res_partner_2") self.product = self.env.ref("product.product_product_4") @@ -49,28 +50,34 @@ class TestPrintCheck(AccountingTestCase): return invoice def create_payment(self, invoices): - register_payments = self.register_payments_model.with_context({ - 'active_model': 'account.invoice', - 'active_ids': invoices.ids - }).create({ - 'payment_date': time.strftime('%Y') + '-07-15', - 'journal_id': self.bank_journal.id, - 'payment_method_id': self.payment_method_check.id, - }) - register_payments.create_payments() - return self.env['account.payment'].search([], order="id desc", limit=1) + payment_register = Form(self.env['account.payment'].with_context(active_model='account.invoice', active_ids=invoices.ids)) + payment_register.payment_date = time.strftime('%Y') + '-07-15' + payment_register.journal_id = self.bank_journal + payment_register.payment_method_id = self.payment_method_check + payment = payment_register.save() + payment.post() + return payment def test_print_check(self): # Make a payment for 10 invoices and 5 credit notes invoices = self.env['account.invoice'] - for i in range(0,15): + for i in range(0, 42): invoices |= self.create_invoice(is_refund=(i % 3 == 0)) payment = self.create_payment(invoices) + self.assertEqual(all(payment.mapped('check_amount_in_words')), True, 'The amount in words is not set on all the payments') + self.assertEqual(all(payment.mapped('check_number')), True, 'The check number is not set on all the payments') # Check the data generated for the report self.env.ref('base.main_company').write({'account_check_printing_multi_stub': True}) report_pages = payment._check_get_pages() - self.assertEqual(len(report_pages), int(math.ceil(len(invoices.ids) / print_check.INV_LINES_PER_STUB))) + self.assertEqual(len(report_pages), int(math.ceil(len(payment.reconciled_invoice_ids) / INV_LINES_PER_STUB))) self.env.ref('base.main_company').write({'account_check_printing_multi_stub': False}) report_pages = payment._check_get_pages() self.assertEqual(len(report_pages), 1) + + def test_from_register(self): + invoices = self.env['account.invoice'] + for i in range(0, 3): + invoices |= self.create_invoice(is_refund=(i % 3 == 0)) + payment = self.create_payment(invoices) + self.assertEqual(all(payment.mapped('check_amount_in_words')), True, 'The amount in words is not set on all the payments') diff --git a/addons/account_check_printing/views/account_payment_views.xml b/addons/account_check_printing/views/account_payment_views.xml index bbb892881e9..82a97e89edc 100644 --- a/addons/account_check_printing/views/account_payment_views.xml +++ b/addons/account_check_printing/views/account_payment_views.xml @@ -19,33 +19,6 @@ </field> </record> - <record id="view_account_payment_invoice_form_inherited" model="ir.ui.view"> - <field name="name">account.payment.invoice.form.inherited</field> - <field name="model">account.payment</field> - <field name="inherit_id" ref="account.view_account_payment_invoice_form" /> - <field name="arch" type="xml"> - <xpath expr="//div[@name='amount_div']" position="after"> - <field name="check_amount_in_words" attrs="{'invisible': [('payment_method_code', '!=', 'check_printing')]}"/> - </xpath> - </field> - </record> - - <record id="view_account_payment_from_invoices_inherited" model="ir.ui.view"> - <field name="name">account.register.payments.wizard.inherited</field> - <field name="model">account.register.payments</field> - <field name="inherit_id" ref="account.view_account_payment_from_invoices" /> - <field name="arch" type="xml"> - <xpath expr="//div[@name='amount_div']" position="after"> - <field name="check_amount_in_words" - attrs="{'invisible': ['|', ('payment_method_code', '!=', 'check_printing'), ('multi', '=', True)]}"/> - </xpath> - <xpath expr="//field[@name='communication']" position="after"> - <field name="check_manual_sequencing" invisible="1" readonly="1"/> - <field name="check_number" attrs="{'invisible': ['|', ('payment_method_code', '!=', 'check_printing'), ('check_manual_sequencing', '=', False)]}"/> - </xpath> - </field> - </record> - <record id="view_payment_check_printing_search" model="ir.ui.view"> <field name="name">account.payment.check.printing.search</field> <field name="model">account.payment</field> diff --git a/addons/account_facturx/__manifest__.py b/addons/account_facturx/__manifest__.py index a615e3f9a45..49de3248eec 100644 --- a/addons/account_facturx/__manifest__.py +++ b/addons/account_facturx/__manifest__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- { - 'name' : 'Import Vendor Bills From XML', + 'name' : 'Import Bills/Invoices From XML', 'version' : '1.0', 'category': 'Accounting', 'depends' : ['account'], diff --git a/addons/account_facturx/models/account_invoice.py b/addons/account_facturx/models/account_invoice.py index 3b5e080162e..50dc6f29c81 100644 --- a/addons/account_facturx/models/account_invoice.py +++ b/addons/account_facturx/models/account_invoice.py @@ -3,6 +3,7 @@ from odoo import api, models, fields, tools, _ from odoo.tools import DEFAULT_SERVER_DATE_FORMAT, float_repr from odoo.tests.common import Form +from odoo.exceptions import UserError from datetime import datetime from lxml import etree @@ -12,6 +13,9 @@ from collections import namedtuple import io import base64 +import logging +_logger = logging.getLogger(__name__) + DEFAULT_FACTURX_DATE_FORMAT = '%Y%m%d' @@ -56,22 +60,23 @@ class AccountInvoice(models.Model): # type must be present in the context to get the right behavior of the _default_journal method (account.invoice). # journal_id must be present in the context to get the right behavior of the _default_account method (account.invoice.line). - self_ctx = self.with_context(type='in_invoice') - journal_id = self_ctx._default_journal().id - self_ctx = self_ctx.with_context(journal_id=journal_id) + journal_id = self._default_journal() + self_ctx = self.with_context(journal_id=journal_id.id) # self could be a single record (editing) or be empty (new). - with Form(self_ctx, view='account.invoice_supplier_form') as invoice_form: + view = journal_id.type == 'purchase' and 'account.invoice_supplier_form' or 'account.invoice_form' + with Form(self_ctx, view=view) as invoice_form: # Partner (first step to avoid warning 'Warning! You must first select a partner.'). - elements = tree.xpath('//ram:SellerTradeParty/ram:SpecifiedTaxRegistration/ram:ID', namespaces=tree.nsmap) + partner_type = journal_id.type == 'purchase' and 'SellerTradeParty' or 'BuyerTradeParty' + elements = tree.xpath('//ram:'+partner_type+'/ram:SpecifiedTaxRegistration/ram:ID', namespaces=tree.nsmap) partner = elements and self.env['res.partner'].search([('vat', '=', elements[0].text)], limit=1) if not partner: - elements = tree.xpath('//ram:SellerTradeParty/ram:Name', namespaces=tree.nsmap) + elements = tree.xpath('//ram:'+partner_type+'/ram:Name', namespaces=tree.nsmap) partner_name = elements and elements[0].text partner = elements and self.env['res.partner'].search([('name', 'ilike', partner_name)], limit=1) if not partner: - elements = tree.xpath('//ram:SellerTradeParty//ram:URIID[@schemeID=\'SMTP\']', namespaces=tree.nsmap) + elements = tree.xpath('//ram:'+partner_type+'//ram:URIID[@schemeID=\'SMTP\']', namespaces=tree.nsmap) partner = elements and self.env['res.partner'].search([('email', '=', elements[0].text)], limit=1) if partner: invoice_form.partner_id = partner @@ -98,7 +103,7 @@ class AccountInvoice(models.Model): # To handle both, we consider the 'a' mode and switch to 'b' if a negative amount is encountered. elements = tree.xpath('//rsm:ExchangedDocument/ram:TypeCode', namespaces=tree.nsmap) type_code = elements[0].text - refund_sign = 1 + refund_sign = type_code == '380' and 1 or -1 # Total amount. elements = tree.xpath('//ram:GrandTotalAmount', namespaces=tree.nsmap) @@ -131,6 +136,8 @@ class AccountInvoice(models.Model): if elements: date_str = elements[0].text date_obj = datetime.strptime(date_str, DEFAULT_FACTURX_DATE_FORMAT) + # Set to empty record set to avoid readonly on date_due, can not set to False or None in a Form + invoice_form.payment_term_id = self.env['account.payment.term'] invoice_form.date_due = date_obj.strftime(DEFAULT_SERVER_DATE_FORMAT) # Invoice lines. @@ -163,7 +170,7 @@ class AccountInvoice(models.Model): # Quantity. line_elements = element.xpath('.//ram:SpecifiedLineTradeDelivery/ram:BilledQuantity', namespaces=tree.nsmap) if line_elements: - invoice_line_form.quantity = float(line_elements[0].text) * refund_sign + invoice_line_form.quantity = float(line_elements[0].text) # Price Unit. line_elements = element.xpath('.//ram:GrossPriceProductTradePrice/ram:ChargeAmount', namespaces=tree.nsmap) @@ -188,7 +195,7 @@ class AccountInvoice(models.Model): tax = self.env['account.tax'].search([ ('company_id', '=', invoice_form.company_id.id), ('amount_type', '=', 'percent'), - ('type_tax_use', '=', 'purchase'), + ('type_tax_use', '=', journal_id.type), ('amount', '=', percentage), ], limit=1) @@ -202,7 +209,10 @@ class AccountInvoice(models.Model): invoice_line_form.price_unit = amount_total_import # Refund. - invoice_form.type = 'in_refund' if refund_sign == -1 else 'in_invoice' + if self_ctx.env.context['journal_type'] == 'purchase': + invoice_form.type = 'in_refund' if refund_sign == -1 else 'in_invoice' + else: + invoice_form.type = 'out_refund' if refund_sign == -1 else 'out_invoice' return invoice_form.save() @@ -213,15 +223,7 @@ class AccountInvoice(models.Model): # /!\ 'default_res_id' in self._context is used to don't process attachment when using a form view. res = super(AccountInvoice, self).message_post(**kwargs) - def _get_attachment_filename(attachment): - # Handle both _Attachment namedtuple in mail.thread or ir.attachment. - return hasattr(attachment, 'fname') and getattr(attachment, 'fname') or attachment.name - - def _get_attachment_content(attachment): - # Handle both _Attachment namedtuple in mail.thread or ir.attachment. - return hasattr(attachment, 'content') and getattr(attachment, 'content') or base64.b64decode(attachment.datas) - - if 'default_res_id' not in self._context and len(self) == 1 and self.state == 'draft' and self.type in ('in_invoice', 'in_refund'): + if 'no_new_invoice' not in self.env.context and len(self) == 1 and self.state == 'draft': # Get attachments. # - 'attachments' is a namedtuple defined in mail.thread looking like: # _Attachment = namedtuple('Attachment', ('fname', 'content', 'info')) @@ -231,37 +233,97 @@ class AccountInvoice(models.Model): attachments += self.env['ir.attachment'].browse(kwargs['attachment_ids']) for attachment in attachments: - filename = _get_attachment_filename(attachment) - content = _get_attachment_content(attachment) - - # Check if the attachment is a pdf. - if not filename.endswith('.pdf'): - continue - - buffer = io.BytesIO(content) - try: - reader = PdfFileReader(buffer) - - # Search for Factur-x embedded file. - if reader.trailer['/Root'].get('/Names') and reader.trailer['/Root']['/Names'].get('/EmbeddedFiles'): - # N.B: embedded_files looks like: - # ['file.xml', {'/Type': '/Filespec', '/F': 'file.xml', '/EF': {'/F': IndirectObject(22, 0)}}] - embedded_files = reader.trailer['/Root']['/Names']['/EmbeddedFiles']['/Names'] - # '[::2]' because it's a list [fn_1, content_1, fn_2, content_2, ..., fn_n, content_2] - for filename_obj, content_obj in list(zip(embedded_files, embedded_files[1:]))[::2]: - content = content_obj.getObject()['/EF']['/F'].getData() - - if filename_obj == 'factur-x.xml': - try: - tree = etree.fromstring(content) - except: - continue - - self._import_facturx_invoice(tree) - buffer.close() - return res - except: - # Malformed PDF. - pass - buffer.close() + self._create_invoice_from_attachment(attachment) return res + + @api.one + def _create_invoice_from_attachment(self, attachment): + if 'pdf' in attachment.mimetype: + self._create_invoice_from_pdf(attachment) + if 'xml' in attachment.mimetype: + self._create_invoice_from_xml(attachment) + + def _create_invoice_from_pdf(self, attachment): + def _get_attachment_filename(attachment): + # Handle both _Attachment namedtuple in mail.thread or ir.attachment. + return hasattr(attachment, 'fname') and getattr(attachment, 'fname') or attachment.name + + def _get_attachment_content(attachment): + # Handle both _Attachment namedtuple in mail.thread or ir.attachment. + return hasattr(attachment, 'content') and getattr(attachment, 'content') or base64.b64decode(attachment.datas) + filename = _get_attachment_filename(attachment) + content = _get_attachment_content(attachment) + + # Check if the attachment is a pdf. + if not filename.endswith('.pdf'): + return + + with io.BytesIO(content) as buffer: + try: + reader = PdfFileReader(buffer) + + # Search for Factur-x embedded file. + if reader.trailer['/Root'].get('/Names') and reader.trailer['/Root']['/Names'].get('/EmbeddedFiles'): + # N.B: embedded_files looks like: + # ['file.xml', {'/Type': '/Filespec', '/F': 'file.xml', '/EF': {'/F': IndirectObject(22, 0)}}] + embedded_files = reader.trailer['/Root']['/Names']['/EmbeddedFiles']['/Names'] + # '[::2]' because it's a list [fn_1, content_1, fn_2, content_2, ..., fn_n, content_2] + for filename_obj, content_obj in list(zip(embedded_files, embedded_files[1:]))[::2]: + content = content_obj.getObject()['/EF']['/F'].getData() + + if filename_obj == 'factur-x.xml': + try: + tree = etree.fromstring(content) + except Exception: + continue + + self._import_facturx_invoice(tree) + buffer.close() + + except Exception as e: + # Malformed pdf + _logger.exception(e) + + @api.model + def _get_xml_decoders(self): + ''' List of usable decoders to extract invoice from attachments. + + :return: a list of triplet (xml_type, check_func, decode_func) + * xml_type: The format name, e.g 'UBL 2.1' + * check_func: A function taking an etree as parameter and returning a dict: + * flag: The etree is part of this format. + * error: Error message. + * decode_func: A function taking an etree as parameter and returning an invoice record. + ''' + # TO BE OVERWRITTEN + return [] + + @api.multi + def _create_invoice_from_xml(self, attachment): + decoders = self._get_xml_decoders() + + # Convert attachment -> etree + content = base64.b64decode(attachment.datas) + try: + tree = etree.fromstring(content) + except Exception: + raise UserError(_('The xml file is badly formatted : {}').format(attachment.datas_fname)) + + for xml_type, check_func, decode_func in decoders: + check_res = check_func(tree) + + if check_res.get('flag') and not check_res.get('error'): + invoice = decode_func(tree) + if invoice: + try: + # don't propose to send to ocr + invoice.extract_state = 'done' + except AttributeError: + # account_invoice_exctract not installed + pass + break + + try: + return invoice + except UnboundLocalError: + raise UserError(_('No decoder was found for the xml file: {}. The file is badly formatted, not supported or the decoder is not installed').format(attachment.datas_fname)) diff --git a/addons/account_payment/views/account_portal_templates.xml b/addons/account_payment/views/account_portal_templates.xml index e9d121e15e0..bdc9c9da488 100644 --- a/addons/account_payment/views/account_portal_templates.xml +++ b/addons/account_payment/views/account_portal_templates.xml @@ -48,7 +48,7 @@ <div t-if="pms or acquirers" id="payment_method" class="text-left col-md-13"> <t t-call="payment.payment_tokens_list"> <t t-set="mode" t-value="'payment'"/> - <t t-set="partner_id" t-value="invoice.partner_id.id"/> + <t t-set="partner_id" t-value="request.env.user.partner_id.id"/> <t t-set="success_url" t-value="invoice.get_portal_url()"/> <t t-set="error_url" t-value="invoice.get_portal_url()"/> <t t-set="access_token" t-value="access_token or ''"/> diff --git a/addons/base_address_city/models/res_partner.py b/addons/base_address_city/models/res_partner.py index f8f01119e7d..c9ee67f2c7b 100644 --- a/addons/base_address_city/models/res_partner.py +++ b/addons/base_address_city/models/res_partner.py @@ -31,13 +31,13 @@ class Partner(models.Model): replacement_xml = """ <div> <field name="country_enforce_cities" invisible="1"/> - <field name='city' placeholder="%(placeholder)s" + <field name='city' placeholder="%(placeholder)s" class="o_address_city" attrs="{ 'invisible': [('country_enforce_cities', '=', True), ('city_id', '!=', False)], 'readonly': [('type', '=', 'contact')%(parent_condition)s] }" /> - <field name='city_id' placeholder="%(placeholder)s" string="%(placeholder)s" + <field name='city_id' placeholder="%(placeholder)s" string="%(placeholder)s" class="o_address_city" context="{'default_country_id': country_id, 'default_name': city, 'default_zipcode': zip, diff --git a/addons/base_address_extended/models/base_address_extended.py b/addons/base_address_extended/models/base_address_extended.py index 766fac55a8b..0fd99cc8e63 100644 --- a/addons/base_address_extended/models/base_address_extended.py +++ b/addons/base_address_extended/models/base_address_extended.py @@ -33,11 +33,6 @@ class Partner(models.Model): street_number2 = fields.Char('Door', compute='_split_street', help="Door Number", inverse='_set_street', store=True) - @api.model - def _address_fields(self): - """Returns the list of address fields that are synced from the parent.""" - return super(Partner, self)._address_fields() + ['street_name', 'street_number', 'street_number2'] - def get_street_fields(self): """Returns the fields that can be used in a street format. Overwrite this function if you want to add your own fields.""" @@ -144,7 +139,7 @@ class Partner(models.Model): def write(self, vals): res = super(Partner, self).write(vals) - if 'country_id' in vals: + if 'country_id' in vals and 'street' not in vals: self._set_street() return res diff --git a/addons/base_geolocalize/models/base_geocoder.py b/addons/base_geolocalize/models/base_geocoder.py index ee54fcf6dbd..6da9c2f7f03 100644 --- a/addons/base_geolocalize/models/base_geocoder.py +++ b/addons/base_geolocalize/models/base_geocoder.py @@ -115,7 +115,12 @@ class GeoCoder(models.AbstractModel): if result['status'] != 'OK': _logger.debug('Invalid Gmaps call: %s - %s', result['status'], result.get('error_message', '')) - return None + error_msg = _('Unable to geolocate, received the error:\n%s' + '\n\nGoogle made this a paid feature.\n' + 'You should first enable billing on your Google account.\n' + 'Then, go to Developer Console, and enable the APIs:\n' + 'Geocoding, Maps Static, Maps Javascript.\n') % result.get('error_message') + raise UserError(error_msg) geo = result['results'][0]['geometry']['location'] return float(geo['lat']), float(geo['lng']) except (KeyError, ValueError): diff --git a/addons/board/static/src/scss/dashboard.scss b/addons/board/static/src/scss/dashboard.scss index d5d95fa9f59..518849cde0a 100644 --- a/addons/board/static/src/scss/dashboard.scss +++ b/addons/board/static/src/scss/dashboard.scss @@ -123,8 +123,8 @@ } // Override height of graph. min-height doesn't do the trick - .o_graph_controller { - height: 500px; + .o_graph_svg_container { + height: 250px; } } } diff --git a/addons/board/static/tests/dashboard_tests.js b/addons/board/static/tests/dashboard_tests.js index 771325e9281..ebd8bcdcc5a 100644 --- a/addons/board/static/tests/dashboard_tests.js +++ b/addons/board/static/tests/dashboard_tests.js @@ -787,6 +787,53 @@ QUnit.test('save two searches to dashboard', async function (assert) { actionManager.destroy(); }); +QUnit.test('save a action domain to dashboard', async function (assert) { + // View domains are to be added to the dashboard domain + assert.expect(1); + + var view_domain = ["display_name", "ilike", "a"]; + var filter_domain = ["display_name", "ilike", "b"]; + + // The filter domain already contains the view domain, but is always added by dashboard.., + var expected_domain = ['&', '&', view_domain, view_domain, filter_domain] + + var actionManager = await createActionManager({ + data: this.data, + archs: { + 'partner,false,list': '<list><field name="foo"/></list>', + 'partner,false,search': '<search></search>', + }, + mockRPC: function (route, args) { + if (route === '/board/add_to_dashboard') { + assert.deepEqual(args.domain, expected_domain, + "the correct domain should be sent"); + return Promise.resolve(true); + } + return this._super.apply(this, arguments); + }, + }); + + await actionManager.doAction({ + id: 1, + res_model: 'partner', + type: 'ir.actions.act_window', + views: [[false, 'list']], + domain: [view_domain], + }); + + // Add a filter + await testUtils.dom.click(actionManager.$('.o_filters_menu_button')); + await testUtils.dom.click(actionManager.$('.o_add_custom_filter')); + actionManager.$('.o_searchview_extended_prop_value .o_input').val('b'); + await testUtils.dom.click(actionManager.$('.o_apply_filter')); + // Add it to dashboard + await testUtils.dom.click(actionManager.$('.o_favorites_menu_button')); + await testUtils.dom.click(actionManager.$('.o_add_to_board')); + await testUtils.dom.click(actionManager.$('.o_add_to_board_confirm_button')); + + actionManager.destroy(); +}); + QUnit.test("Views should be loaded in the user's language", async function (assert) { assert.expect(2); var form = await createView({ diff --git a/addons/calendar/models/res_partner.py b/addons/calendar/models/res_partner.py index 403d70b18fc..261b955fc8f 100644 --- a/addons/calendar/models/res_partner.py +++ b/addons/calendar/models/res_partner.py @@ -11,7 +11,7 @@ from odoo.addons.calendar.models.calendar import get_real_ids class Partner(models.Model): _inherit = 'res.partner' - calendar_last_notif_ack = fields.Datetime('Last notification marked as read from base Calendar') + calendar_last_notif_ack = fields.Datetime('Last notification marked as read from base Calendar', default=fields.Datetime.now) @api.multi def get_attendee_detail(self, meeting_id): diff --git a/addons/delivery/models/product_template.py b/addons/delivery/models/product_template.py index 79465b50303..eceb08e58e9 100644 --- a/addons/delivery/models/product_template.py +++ b/addons/delivery/models/product_template.py @@ -7,4 +7,7 @@ from odoo import models, fields class ProductTemplate(models.Model): _inherit = 'product.template' - hs_code = fields.Char(string="HS Code", help="Standardized code for international shipping and goods declaration") + hs_code = fields.Char( + string="HS Code", + help="Standardized code for international shipping and goods declaration. At the moment, only used for the FedEx shipping provider.", + ) diff --git a/addons/digest/models/digest.py b/addons/digest/models/digest.py index 7809648e48c..eb4fd1190bb 100644 --- a/addons/digest/models/digest.py +++ b/addons/digest/models/digest.py @@ -133,7 +133,7 @@ class Digest(models.Model): tip = self.env['digest.tip'].search([('user_ids', '!=', user.id), '|', ('group_id', 'in', user.groups_id.ids), ('group_id', '=', False)], limit=1) if not tip: return False - tip.user_ids = [4, user.id] + tip.user_ids += user body = tools.html_sanitize(tip.tip_description) tip_description = self.env['mail.template']._render_template(body, 'digest.tip', self.id) return tip_description diff --git a/addons/hr_expense/wizard/hr_expense_sheet_register_payment.py b/addons/hr_expense/wizard/hr_expense_sheet_register_payment.py index f77f013d999..76a97f6a561 100644 --- a/addons/hr_expense/wizard/hr_expense_sheet_register_payment.py +++ b/addons/hr_expense/wizard/hr_expense_sheet_register_payment.py @@ -29,6 +29,7 @@ class HrExpenseSheetRegisterPaymentWizard(models.TransientModel): hide_payment_method = fields.Boolean(compute='_compute_hide_payment_method', help="Technical field used to hide the payment method if the selected journal has only one available which is 'manual'") show_partner_bank_account = fields.Boolean(compute='_compute_show_partner_bank', help='Technical field used to know whether the field `partner_bank_account_id` needs to be displayed or not in the payments form views') + require_partner_bank_account = fields.Boolean(compute='_compute_show_partner_bank', help='Technical field used to know whether the field `partner_bank_account_id` needs to be required or not in the payments form views') @api.onchange('partner_id') def _onchange_partner_id(self): @@ -53,6 +54,7 @@ class HrExpenseSheetRegisterPaymentWizard(models.TransientModel): won't be displayed but some modules might change that, depending on the payment type.""" for payment in self: payment.show_partner_bank_account = payment.payment_method_id.code in self.env['account.payment']._get_method_codes_using_bank_account() + payment.require_partner_bank_account = payment.payment_method_id.code in self.env['account.payment']._get_method_codes_needing_bank_account() @api.one @api.depends('journal_id') diff --git a/addons/hr_expense/wizard/hr_expense_sheet_register_payment.xml b/addons/hr_expense/wizard/hr_expense_sheet_register_payment.xml index 2f0c668767a..e82febdc15e 100644 --- a/addons/hr_expense/wizard/hr_expense_sheet_register_payment.xml +++ b/addons/hr_expense/wizard/hr_expense_sheet_register_payment.xml @@ -17,7 +17,8 @@ <field name="hide_payment_method" invisible="1"/> <field name="payment_method_id" widget="radio" attrs="{'invisible': [('hide_payment_method', '=', True)]}"/> <field name="show_partner_bank_account" invisible="1"/> - <field name="partner_bank_account_id" attrs="{'invisible': [('show_partner_bank_account', '!=', True)], 'required': [('show_partner_bank_account', '=', True)]}" domain="[('partner_id', '=', partner_id)]" context="{'default_partner_id': partner_id}"/> + <field name="require_partner_bank_account" invisible="1"/> + <field name="partner_bank_account_id" attrs="{'invisible': [('show_partner_bank_account', '!=', True)], 'required': [('require_partner_bank_account', '=', True)]}" domain="[('partner_id', '=', partner_id)]" context="{'default_partner_id': partner_id}"/> <label for="amount"/> <div name="amount_div" class="o_row"> <field name="amount"/> diff --git a/addons/hr_holidays/models/hr_leave.py b/addons/hr_holidays/models/hr_leave.py index 748af9b1a32..34afa16eb5d 100644 --- a/addons/hr_holidays/models/hr_leave.py +++ b/addons/hr_holidays/models/hr_leave.py @@ -478,7 +478,7 @@ class HolidaysRequest(models.Model): self.message_subscribe(partner_ids=employee.user_id.partner_id.ids) @api.multi - @api.constrains('holiday_status_id') + @api.constrains('holiday_status_id', 'date_to', 'date_from') def _check_leave_type_validity(self): for leave in self: if leave.holiday_status_id.validity_start and leave.holiday_status_id.validity_stop: diff --git a/addons/hr_timesheet/models/hr_timesheet.py b/addons/hr_timesheet/models/hr_timesheet.py index 0542428f25c..7d1edf4b2b0 100644 --- a/addons/hr_timesheet/models/hr_timesheet.py +++ b/addons/hr_timesheet/models/hr_timesheet.py @@ -42,7 +42,10 @@ class AccountAnalyticLine(models.Model): @api.onchange('employee_id') def _onchange_employee_id(self): - self.user_id = self.employee_id.user_id + if self.employee_id: + self.user_id = self.employee_id.user_id + else: + self.user_id = self._default_user() @api.depends('employee_id') def _compute_department_id(self): diff --git a/addons/http_routing/models/ir_http.py b/addons/http_routing/models/ir_http.py index b8356cf13b5..12c81e9d620 100644 --- a/addons/http_routing/models/ir_http.py +++ b/addons/http_routing/models/ir_http.py @@ -165,7 +165,7 @@ def is_multilang_url(local_url, langs=None): local_url = '/'.join(spath) try: # Try to match an endpoint in werkzeug's routing table - url = local_url.split('?') + url = local_url.partition('#')[0].split('?') path = url[0] query_string = url[1] if len(url) > 1 else None router = request.httprequest.app.get_db_router(request.db).bind('') @@ -174,6 +174,10 @@ def is_multilang_url(local_url, langs=None): return (func.routing.get('website', False) and func.routing.get('multilang', func.routing['type'] == 'http')) except werkzeug.exceptions.NotFound: + # Consider /static/ files as non-multilang + static_index = path.find('/static/', 1) + if static_index != -1 and static_index == path.find('/', 1): + return False return True except Exception as e: return False diff --git a/addons/hw_drivers/controllers/__init__.py b/addons/hw_drivers/controllers/__init__.py index 3bd726d7111..8a59703b3c1 100644 --- a/addons/hw_drivers/controllers/__init__.py +++ b/addons/hw_drivers/controllers/__init__.py @@ -1,2 +1 @@ -from . import driver -from . import loading
\ No newline at end of file +from . import driver
\ No newline at end of file diff --git a/addons/hw_drivers/controllers/driver.py b/addons/hw_drivers/controllers/driver.py index 3772dfa32f9..64d45a1fcfc 100755 --- a/addons/hw_drivers/controllers/driver.py +++ b/addons/hw_drivers/controllers/driver.py @@ -1,432 +1,401 @@ #!/usr/bin/python3 import logging import time -from threading import Thread -import usb -import gatt +from threading import Thread, Event +from usb import core +from gatt import DeviceManager as Gatt_DeviceManager import subprocess -import netifaces as ni +import netifaces import json -import re -from odoo import http +from re import sub import urllib3 -from odoo.http import request as httprequest -import datetime +import os +import socket +from importlib import util +import v4l2 +from fcntl import ioctl +from cups import Connection as cups_connection +from glob import glob +from base64 import b64decode +from pathlib import Path -logging.basicConfig(level=logging.DEBUG, format='%(asctime)s %(levelname)s %(name)s: %(message)s') -_logger = logging.getLogger('dispatcher') +from odoo import http, _ +from odoo.modules.module import get_resource_path -owner_dict = {} -last_ping = {} +_logger = logging.getLogger(__name__) -class StatusController(http.Controller): - - @http.route('/hw_drivers/owner/check', type='json', auth='none', cors='*', csrf=False) - def check_cantakeowner(self): #, devices, tab - data = httprequest.jsonrequest - for device in data['devices']: - if owner_dict.get(device) and owner_dict[device] != data['tab']: - before_date = datetime.datetime.now() - datetime.timedelta(seconds=10) - if last_ping.get(owner_dict[device]) and last_ping.get(owner_dict[device]) > before_date: - return 'no' - else: - old_tab = owner_dict[device] - for dev2 in owner_dict: - if owner_dict[dev2] == old_tab: - owner_dict[dev2] = '' - return 'yes' - - @http.route('/hw_drivers/owner/take', type='json', auth='none', cors='*', csrf=False) - def take_ownership(self): #, devices, tab - data = httprequest.jsonrequest - for device in data['devices']: - owner_dict[device] = data['tab'] - last_ping[data['tab']] = datetime.datetime.now() - return data['tab'] - - @http.route('/hw_drivers/owner/ping', type='json', auth='none', cors='*', csrf=False) - def ping_trigger(self): #, tab - data = httprequest.jsonrequest - ping_dict = {} - last_ping[data['tab']] = datetime.datetime.now() - for dev in data['devices']: - if owner_dict.get(dev) and owner_dict[dev] == data['tab']: - for driver_path in drivers: - if driver_path.find(dev) == 0 and drivers[driver_path].ping_value: - ping_dict[dev] = drivers[driver_path].ping_value - drivers[driver_path].ping_value = '' # or set it to nothing - else: - ping_dict[dev] = 'STOP' - return ping_dict - - @http.route('/hw_drivers/box/connect', type='json', auth='none', cors='*', csrf=False) - def connect_box(self): - data = httprequest.jsonrequest - server = "" # read from file - try: - f = open('/home/pi/odoo-remote-server.conf', 'r') - for line in f: - server += line - f.close() - server = server.split('\n')[0] - except: - server = '' - if server: - return 'This IoTBox has already been connected' - else: - iotname = '' - token = data['token'].split('|')[1] - url = data['token'].split('|')[0] - reboot = 'noreboot' - subprocess.call(['/home/pi/odoo/addons/point_of_sale/tools/posbox/configuration/connect_to_server.sh', url, iotname, token, reboot]) - send_iot_box_device(False) - return 'IoTBox connected' - - @http.route('/hw_drivers/drivers/status', type='http', auth='none', cors='*') - def status(self): - result = "<html><head></head><body>List of drivers and values: <br/> <ul>" - for path in drivers: - result += "<li>" + path + ":" + str(drivers[path].value) + "</li>" - result += "</ul>" - result +=" </body></html>" - return result - - @http.route('/hw_drivers/driverdetails/<string:identifier>', type='http', auth='none', cors='*') - def statusdetail(self, identifier): - for device in drivers: - if device.find(identifier) != -1: - return str(drivers[device].value) - return '' - - @http.route('/hw_drivers/driveraction/<string:identifier>', type='json', auth='none', cors='*', csrf=False) - def driveraction(self, identifier): - data = httprequest.jsonrequest - result = 'device not found' - if data.get('action') == 'print': - with open('/tmp/toprinter', 'w') as file: - file.write(data['data']) - subprocess.call("cat /tmp/toprinter | base64 -d | lp -d " + identifier, shell=True) - result = 'ok' - if data.get('action') == 'camera': - cameras = subprocess.check_output("v4l2-ctl --list-devices", shell=True).decode('utf-8').split('\n\n') - adrress = '/dev/video0' - for camera in cameras: - if camera: - camera = camera.split('\n\t') - serial = re.sub('[^a-zA-Z0-9 ]+', '', camera[0].split(': ')[0]).replace(' ','_') - if serial == data.get('identifier'): - adrress = camera[1] - picture = subprocess.check_output("v4l2-ctl --list-formats-ext|grep 'Size'|awk '{print $3}'|sort -rn|awk NR==1", shell=True).decode('utf-8') - subprocess.call("fswebcam -d " + adrress + " /tmp/testimage -r " + picture, shell=True) - image_bytes = subprocess.check_output('cat /tmp/testimage | base64',shell=True) - result = {'image': image_bytes} - return result - - @http.route('/hw_drivers/send_iot_box', type='http', auth='none', cors='*') - def send_iot_box(self): - send_iot_box_device(False) - return 'ok' #---------------------------------------------------------- -# Driver common interface +# Helper #---------------------------------------------------------- -class Driver(Thread): - ping_value = "" - - def supported(self): - pass - - def value(self): - pass - - def get_name(self): - pass - - def get_connection(self): - pass - - def action(self, action): - pass -#---------------------------------------------------------- -# Usb drivers -#---------------------------------------------------------- -usbdrivers = [] -drivers = {} - -class UsbMetaClass(type): - def __new__(cls, clsname, bases, attrs): - newclass = super(UsbMetaClass, cls).__new__(cls, clsname, bases, attrs) - usbdrivers.append(newclass) - return newclass +def get_mac_address(): + try: + return netifaces.ifaddresses('eth0')[netifaces.AF_LINK][0]['addr'] + except: + return netifaces.ifaddresses('wlan0')[netifaces.AF_LINK][0]['addr'] -class USBDriver(Driver,metaclass=UsbMetaClass): - def __init__(self, dev): - super(USBDriver, self).__init__() - self.dev = dev - self.value = "" +def get_ip(): + try: + return netifaces.ifaddresses('eth0')[netifaces.AF_INET][0]['addr'] + except: + return netifaces.ifaddresses('wlan0')[netifaces.AF_INET][0]['addr'] - def get_name(self): - lsusb = str(subprocess.check_output('lsusb')).split("\\n") - for usbpath in lsusb: # Should filter on usb devices or inverse loops? - device = self.dev - if "%04x:%04x" % (device.idVendor, device.idProduct) in usbpath: - return usbpath.split("%04x:%04x" % (device.idVendor, device.idProduct))[1] - return str(device.idVendor) + ":" + str(device.idProduct) +def read_file_first_line(filename): + path = Path.home() / filename + if path.exists(): + with path.open('r') as f: + return f.readline().strip('\n') + return '' - def get_connection(self): - return 'direct' +def get_odoo_server_url(): + return read_file_first_line('odoo-remote-server.conf') - def value(self): - return self.value +def get_token(): + return read_file_first_line('token') +def get_version(): + return '19_04' #---------------------------------------------------------- -# Bluetooth +# Controllers #---------------------------------------------------------- -class GattBtManager(gatt.DeviceManager): - def device_discovered(self, device): - # TODO: need some kind of updated_devices mechanism or not? - path = "bt_%s" % (device.mac_address,) - if path not in drivers: - for driverclass in btdrivers: - d = driverclass(device = device, manager=self) - if d.supported(): - drivers[path] = d - d.connect() - send_iot_box_device(False) - - -class BtManager(Thread): - gatt_manager = False - - def run(self): - dm = GattBtManager(adapter_name='hci0') - self.gatt_manager = dm - dm.start_discovery() - dm.run() +class StatusController(http.Controller): + @http.route('/hw_drivers/action', type='json', auth='none', cors='*', csrf=False, save_session=False) + def action(self, session_id, device_id, data): + """ + This route is called when we want to make a action with device (take picture, printing,...) + We specify in data from which session_id that action is called + And call the action of specific device + """ + iot_device = iot_devices.get(device_id) + if iot_device: + iot_device.data['owner'] = session_id + data = json.loads(data) + iot_device.action(data) + return True + return False + + @http.route('/hw_drivers/event', type='json', auth='none', cors='*', csrf=False, save_session=False) + def event(self, listener): + """ + listener is a dict in witch there are a sessions_id and a dict of device_id to listen + """ + req = event_manager.add_request(listener) + if req['event'].wait(50): + req['event'].clear() + req['result']['session_id'] = req['session_id'] + return req['result'] + + @http.route('/hw_drivers/box/connect', type='http', auth='none', cors='*', csrf=False, save_session=False) + def connect_box(self, token): + """ + This route is called when we want that a IoT Box will be connected to a Odoo DB + token is a base 64 encoded string and have 2 argument separate by | + 1 - url of odoo DB + 2 - token. This token will be compared to the token of Odoo. He have 1 hour lifetime + """ + server = get_odoo_server_url() + image = get_resource_path('hw_drivers', 'static/img', 'False.jpg') + if server == '': + token = b64decode(token).decode('utf-8') + url, token = token.split('|') + try: + subprocess.check_call([get_resource_path('point_of_sale', 'tools/posbox/configuration/connect_to_server.sh'), url, '', token, 'noreboot']) + m.send_alldevices() + image = get_resource_path('hw_drivers', 'static/img', 'True.jpg') + except subprocess.CalledProcessError as e: + _logger.error('A error encountered : %s ' % e.output) + if os.path.isfile(image): + with open(image, 'rb') as f: + return f.read() #---------------------------------------------------------- -# Bluetooth drivers +# Drivers #---------------------------------------------------------- -btdrivers = [] -class BtMetaClass(type): +drivers = [] +bt_devices = {} +iot_devices = {} + +class DriverMetaClass(type): def __new__(cls, clsname, bases, attrs): - newclass = super(BtMetaClass, cls).__new__(cls, clsname, bases, attrs) - btdrivers.append(newclass) + newclass = super(DriverMetaClass, cls).__new__(cls, clsname, bases, attrs) + drivers.append(newclass) return newclass +class Driver(Thread, metaclass=DriverMetaClass): + """ + Hook to register the driver into the drivers list + """ + connection_type = "" -class BtDriver(Driver, metaclass=BtMetaClass): - - - def __init__(self, device, manager): - super(BtDriver, self).__init__() + def __init__(self, device): + super(Driver, self).__init__() self.dev = device - self.manager = manager - self.value = '' + self.data = {'value': ''} self.gatt_device = False - def disconnect(self): - path = "bt_%s" % (self.dev.mac_address,) - del drivers[path] + @property + def device_name(self): + return self._device_name + + @property + def device_identifier(self): + return self._device_identifier + + @property + def device_connection(self): + """ + On specific driver override this method to give connection type of device + return string + possible value : direct - network - bluetooth + """ + return self._device_connection + + @property + def device_type(self): + """ + On specific driver override this method to give type of device + return string + possible value : printer - camera - device + """ + return self._device_type + + @classmethod + def supported(cls, device): + """ + On specific driver override this method to check if device is supported or not + return True or False + """ + pass - def get_name(self): - return self.dev.alias() + def get_message(self): + return '' - def value(self): - return self.value + def action(self, data): + """ + On specific driver override this method to make a action with device (take picture, printing,...) + """ + raise NotImplementedError() - def action(self, action): - pass + def disconnect(self): + del iot_devices[self.device_identifier] - def get_connection(self): - return 'bluetooth' - def connect(self): - pass +#---------------------------------------------------------- +# Device manager +#---------------------------------------------------------- +class EventManager(object): + def __init__(self): + self.sessions = {} + + def _delete_expired_sessions(self, max_time=70): + ''' + Clears sessions that are no longer called. + + :param max_time: time a session can stay unused before being deleted + ''' + now = time.time() + expired_sessions = [session for session in self.sessions if now - self.sessions[session]['time_request'] > max_time] + for session in expired_sessions: + del self.sessions[session] + + def add_request(self, listener): + self.session = { + 'session_id': listener['session_id'], + 'devices': listener['devices'], + 'event': Event(), + 'result': {}, + 'time_request': time.time(), + } + self._delete_expired_sessions() + self.sessions[listener['session_id']] = self.session + return self.sessions[listener['session_id']] + + def device_changed(self, device): + for session in self.sessions: + if device.device_identifier in self.sessions[session]['devices']: + self.sessions[session]['result'] = device.data + self.sessions[session]['result']['device_id'] = device.device_identifier + self.sessions[session]['event'].set() + + +class IoTDevice(object): + + def __init__(self, dev, connection_type): + self.dev = dev + self.connection_type = connection_type +event_manager = EventManager() +#---------------------------------------------------------- +# Manager +#---------------------------------------------------------- +class Manager(Thread): + + def __init__(self): + super(Manager, self).__init__() + self.load_drivers() + + def load_drivers(self): + """ + This method loads local files: 'odoo/addons/hw_drivers/drivers' + And execute these python drivers + """ + path = get_resource_path('hw_drivers', 'drivers') + driversList = os.listdir(path) + for driver in driversList: + path_file = os.path.join(path, driver) + spec = util.spec_from_file_location(driver, path_file) + if spec: + module = util.module_from_spec(spec) + spec.loader.exec_module(module) + + def send_alldevices(self): + """ + This method send IoT Box and devices informations to Odoo database + """ + server = get_odoo_server_url() + if server: + iot_box = { + 'name': socket.gethostname(), + 'identifier': get_mac_address(), + 'ip': get_ip(), + 'token': get_token(), + 'version': get_version() + } + devices_list = {} + for device in iot_devices: + identifier = iot_devices[device].device_identifier + devices_list[identifier] = { + 'name': iot_devices[device].device_name, + 'type': iot_devices[device].device_type, + 'connection': iot_devices[device].device_connection, + } + data = { + 'params': { + 'iot_box' : iot_box, + 'devices' : devices_list, + } + } + # disable certifiacte verification + urllib3.disable_warnings() + http = urllib3.PoolManager(cert_reqs='CERT_NONE') + try: + http.request( + 'POST', + server + "/iot/setup", + body = json.dumps(data).encode('utf8'), + headers = {'Content-type': 'application/json', 'Accept': 'text/plain'} + ) + except Exception as e: + _logger.error('Could not reach configured server') + _logger.error('A error encountered : %s ' % e) + else: + _logger.warning('Odoo server not set') + + def usb_loop(self): + usb_devices = {} + devs = core.find(find_all=True) + for dev in devs: + path = "usb_%04x:%04x_%03d_%03d_" % (dev.idVendor, dev.idProduct, dev.bus, dev.address) + iot_device = IoTDevice(dev, 'usb') + usb_devices[path] = iot_device + return usb_devices + + def video_loop(self): + camera_devices = {} + videos = glob('/dev/video*') + for video in videos: + with open(video, 'w') as path: + dev = v4l2.v4l2_capability() + ioctl(path, v4l2.VIDIOC_QUERYCAP, dev) + dev.interface = video + iot_device = IoTDevice(dev, 'video') + camera_devices[dev.bus_info.decode('utf-8')] = iot_device + return camera_devices + + def printer_loop(self): + printer_devices = {} + devices = conn.getDevices() + for path in [printer_lo for printer_lo in devices if devices[printer_lo]['device-make-and-model'] != 'Unknown']: + if 'uuid=' in path: + serial = sub('[^a-zA-Z0-9 ]+', '', path.split('uuid=')[1]) + elif 'serial=' in path: + serial = sub('[^a-zA-Z0-9 ]+', '', path.split('serial=')[1]) + else: + serial = sub('[^a-zA-Z0-9 ]+', '', path) + devices[path]['identifier'] = serial + devices[path]['url'] = path + iot_device = IoTDevice(devices[path], 'printer') + printer_devices[serial] = iot_device + return printer_devices -class USBDeviceManager(Thread): - devices = {} def run(self): - first_time = True - send_iot_box_device(False) + """ + Thread that will check connected/disconnected device, load drivers if needed and contact the odoo server with the updates + """ + devices = {} + updated_devices = {} + self.send_alldevices() + cpt = 0 while 1: - sendJSON = False - devs = usb.core.find(find_all=True) - updated_devices = {} - for dev in devs: - path = "usb_%04x:%04x_%03d_%03d_" % (dev.idVendor, dev.idProduct, dev.bus, dev.address) - updated_devices[path] = self.devices.get(path, dev) - added = updated_devices.keys() - self.devices.keys() - removed = self.devices.keys() - updated_devices.keys() - self.devices = updated_devices - if (removed): - for path in list(drivers): - if (path in removed): - del drivers[path] - sendJSON = True - for path in added: - dev = updated_devices[path] - for driverclass in usbdrivers: - d = driverclass(updated_devices[path]) - if d.supported(): + updated_devices = self.usb_loop() + updated_devices.update(self.video_loop()) + updated_devices.update(bt_devices) + if cpt % 40 == 0: + printer_devices = self.printer_loop() + cpt = 0 + updated_devices.update(printer_devices) + cpt += 1 + added = updated_devices.keys() - devices.keys() + removed = devices.keys() - updated_devices.keys() + devices = updated_devices + for path in [device_rm for device_rm in removed if device_rm in iot_devices]: + iot_devices[path].disconnect() + for path in [device_add for device_add in added if device_add not in iot_devices]: + for driverclass in [d for d in drivers if d.connection_type == devices[path].connection_type]: + if driverclass.supported(device = updated_devices[path].dev): _logger.info('For device %s will be driven', path) - drivers[path] = d - # launch thread + d = driverclass(device = updated_devices[path].dev) d.daemon = True d.start() - sendJSON = True - if sendJSON or first_time: - send_iot_box_device(send_printer = first_time) - first_time = False + iot_devices[path] = d + self.send_alldevices() + break time.sleep(3) - +class GattBtManager(Gatt_DeviceManager): + + def device_discovered(self, device): + path = "bt_%s" % (device.mac_address,) + if path not in bt_devices: + device.manager = self + iot_device = IoTDevice(device, 'bluetooth') + bt_devices[path] = iot_device +class BtManager(Thread): + def run(self): + dm = GattBtManager(adapter_name='hci0') + for device in [device_con for device_con in dm.devices() if device_con.is_connected()]: + device.disconnect() + dm.start_discovery() + dm.run() -def send_iot_box_device(send_printer): - maciotbox = subprocess.check_output("/sbin/ifconfig eth0 |grep -Eo ..\(\:..\){5}", shell=True).decode('utf-8').split('\n')[0] - server = "" # read from file - try: - f = open('/home/pi/odoo-remote-server.conf', 'r') - for line in f: - server += line - f.close() - except: #In case the file does not exist - server='' - server = server.split('\n')[0] - if server: - url = server + "/iot/setup" - interfaces = ni.interfaces() - for iface_id in interfaces: - iface_obj = ni.ifaddresses(iface_id) - ifconfigs = iface_obj.get(ni.AF_INET, []) - for conf in ifconfigs: - if conf.get('addr') and conf.get('addr') != '127.0.0.1': - ips = conf.get('addr') - break - - # Build device JSON - devicesList = {} - for path in drivers: - device_name = drivers[path].get_name() - device_connection = drivers[path].get_connection() - identifier = path.split('_')[0] + '_' + path.split('_')[1] - devicesList[identifier] = {'name': device_name, - 'type': 'device', - 'connection': device_connection} - - # Build camera JSON - try: - cameras = subprocess.check_output("v4l2-ctl --list-devices", shell=True).decode('utf-8').split('\n\n') - for camera in cameras: - if camera: - camera = camera.split('\n\t') - serial = re.sub('[^a-zA-Z0-9 ]+', '', camera[0].split(': ')[0]).replace(' ','_') - devicesList[serial] = { - 'name': camera[0].split(': ')[0], - 'connection': 'direct', - 'type': 'camera' - } - except: - pass - - # Build printer JSON - printerList = {} - if send_printer: - printers = subprocess.check_output("sudo lpinfo -lv", shell=True).decode('utf-8').split('Device') - printers_installed = '' - try: - printers_installed = subprocess.check_output("sudo lpstat -a", shell=True).decode('utf-8') - except: - pass - for printer in printers: - printerTab = printer.split('\n') - if printer and printerTab[4].split('=')[1] != ' ' or 'lpd://' in printerTab[0]: - device_connection = printerTab[1].split('= ')[1] - model = '' - for device_id in printerTab[4].split('= ')[1].split(';'): - if any(x in device_id for x in ['MDL','MODEL']): - model = device_id.split(':')[1] - serial = re.sub('[^a-zA-Z0-9 ]+', '', model).replace(' ','_') - identifier = '' - if device_connection == 'direct': - identifier = serial + '_' + maciotbox #name + macIOTBOX - elif device_connection == 'network' and 'socket' in printerTab[0]: - socketIP = printerTab[0].split('://')[1] - macprinter = subprocess.check_output("arp -a " + socketIP + " |awk NR==1'{print $4}'", shell=True).decode('utf-8').split('\n')[0] - identifier = macprinter # macPRINTER - elif device_connection == 'network' and 'lpd' in printerTab[0]: - identifier = re.sub('[^a-zA-Z0-9 ]+', '', printerTab[0].split('://')[1]) - model = printerTab[2].split('= ')[1] - elif device_connection == 'network' and 'dnssd' in printerTab[0]: - hostname_printer = subprocess.check_output("ippfind -n \"" + model + "\" | awk \'{split($0,a,\"/\"); print a[3]}\' | awk \'{split($0,b,\":\"); print b[1]}\'", shell=True).decode('utf-8').split('\n')[0] - if hostname_printer: - macprinter = subprocess.check_output("arp -a " + hostname_printer + " |awk NR==1'{print $4}'", shell=True).decode('utf-8').split('\n')[0] - identifier = macprinter # macprinter - - identifier = identifier.replace(':','_') - if identifier and identifier not in printerList: - printerList[identifier] = { - 'name': model, - 'connection': device_connection, - 'type': 'printer' - } - - if identifier not in printers_installed: - # install these printers - try: - ppd = subprocess.check_output("sudo lpinfo -m |grep '" + model + "'", shell=True).decode('utf-8').split('\n') - if len(ppd) > 2: - subprocess.call("sudo lpadmin -p '" + identifier + "' -E -v '" + printerTab[0].split('= ')[1] + "'", shell=True) - else: - subprocess.call("sudo lpadmin -p '" + identifier + "' -E -v '" + printerTab[0].split('= ')[1] + "' -m '" + ppd[0].split(' ')[0] + "'", shell=True) - except: - subprocess.call("sudo lpadmin -p '" + identifier + "' -E -v '" + printerTab[0].split('= ')[1] + "'", shell=True) - subprocess.call('> /tmp/printers', shell=True) - for printer in printerList: - subprocess.call('echo "' + printerList[printer]['name'] + '" >> /tmp/printers', shell=True) - - if devicesList: - subprocess.call('> /tmp/devices', shell=True) - for device in devicesList: - subprocess.call('echo "' + str(device) + '|' + devicesList[device]['name'] + '" >> /tmp/devices', shell=True) - - #build JSON with all devices - hostname = subprocess.check_output('hostname').decode('utf-8').split('\n')[0] - token = "" # read from file - try: - f = open('/home/pi/token', 'r') - for line in f: - token += line - f.close() - except: #In case the file does not exist - token='' - token = token.split('\n')[0] - data = {'name': hostname,'identifier': maciotbox, 'ip': ips, 'token': token} - devicesList.update(printerList) - data['devices'] = devicesList - data_json = json.dumps(data).encode('utf8') - headers = {'Content-type': 'application/json', 'Accept': 'text/plain'} - urllib3.disable_warnings() - http = urllib3.PoolManager(cert_reqs='CERT_NONE') - req = False - try: - req = http.request('POST', - url, - body=data_json, - headers=headers) - except Exception as e: - _logger.warning('Could not reach configured server') - _logger.error('A error encountered : %s ' % e) +conn = cups_connection() +PPDs = conn.getPPDs() +printers = conn.getPrinters() +m = Manager() +m.daemon = True +m.start() +bm = BtManager() +bm.daemon = True +bm.start() diff --git a/addons/hw_drivers/controllers/loading.py b/addons/hw_drivers/controllers/loading.py deleted file mode 100644 index cd02d3dd82f..00000000000 --- a/addons/hw_drivers/controllers/loading.py +++ /dev/null @@ -1,34 +0,0 @@ -import importlib.util -import os -from .driver import BtManager -from .driver import USBDeviceManager -from odoo import modules - -driversList = os.listdir("/home/pi/odoo/addons/hw_drivers/drivers") -for driver in driversList: - path = "/home/pi/odoo/addons/hw_drivers/drivers/" + driver - spec = importlib.util.spec_from_file_location(driver, path) - if spec: - foo = importlib.util.module_from_spec(spec) - spec.loader.exec_module(foo) - - -if not getattr(modules, '_iot_daemon_started', False): - # ---------------------------------------------------------- - # Bluetooth start - # ---------------------------------------------------------- - bm = BtManager() - bm.daemon = True - bm.start() - - #---------------------------------------------------------- - #USB start - #---------------------------------------------------------- - - udm = USBDeviceManager() - udm.daemon = True - udm.start() - - # Did this because of the - modules._iot_daemon_started = True - diff --git a/addons/hw_drivers/static/img/False.jpg b/addons/hw_drivers/static/img/False.jpg Binary files differnew file mode 100644 index 00000000000..8f8e6489b30 --- /dev/null +++ b/addons/hw_drivers/static/img/False.jpg diff --git a/addons/hw_drivers/static/img/True.jpg b/addons/hw_drivers/static/img/True.jpg Binary files differnew file mode 100644 index 00000000000..0488574a699 --- /dev/null +++ b/addons/hw_drivers/static/img/True.jpg diff --git a/addons/hw_posbox_homepage/controllers/main.py b/addons/hw_posbox_homepage/controllers/main.py index f3de5a0d138..538b6acbf42 100644 --- a/addons/hw_posbox_homepage/controllers/main.py +++ b/addons/hw_posbox_homepage/controllers/main.py @@ -7,7 +7,7 @@ import jinja2 import subprocess import socket import sys -import netifaces as ni +import netifaces import odoo from odoo import http import zipfile @@ -15,12 +15,28 @@ import io import os from odoo.tools import misc import urllib3 +from pathlib import Path from uuid import getnode as get_mac from odoo.addons.hw_proxy.controllers import main as hw_proxy +from odoo.addons.web.controllers import main as web +from odoo.modules.module import get_resource_path +from odoo.addons.hw_drivers.controllers.driver import iot_devices, get_ip, get_odoo_server_url _logger = logging.getLogger(__name__) + +#---------------------------------------------------------- +# Helper +#---------------------------------------------------------- + +def access_point(): + return get_ip() == '10.11.12.1' + +#---------------------------------------------------------- +# Controllers +#---------------------------------------------------------- + if hasattr(sys, 'frozen'): # When running on compiled windows binary, we don't have access to package loader. path = os.path.realpath(os.path.join(os.path.dirname(__file__), '..', 'views')) @@ -38,18 +54,7 @@ driver_list_template = jinja_env.get_template('driver_list.html') remote_connect_template = jinja_env.get_template('remote_connect.html') configure_wizard_template = jinja_env.get_template('configure_wizard.html') -class IoTboxHomepage(odoo.addons.web.controllers.main.Home): - - def get_ip_iotbox(self): - interfaces = ni.interfaces() - for iface_id in interfaces: - iface_obj = ni.ifaddresses(iface_id) - ifconfigs = iface_obj.get(ni.AF_INET, []) - for conf in ifconfigs: - if conf.get('addr') and conf.get('addr') != '127.0.0.1': - ips = conf.get('addr') - break - return ips +class IoTboxHomepage(web.Home): def get_hw_screen_message(self): return """ @@ -67,18 +72,6 @@ class IoTboxHomepage(odoo.addons.web.controllers.main.Home): statuses[driver] = hw_proxy.drivers[driver].get_status() return statuses - def get_server_status(self): - server_template = "" - try: - f = open('/home/pi/odoo-remote-server.conf', 'r') - for line in f: - server_template += line - f.close() - except: - return '' - - return server_template.split('\n')[0] - def get_homepage_data(self): hostname = str(socket.gethostname()) mac = get_mac() @@ -88,7 +81,7 @@ class IoTboxHomepage(odoo.addons.web.controllers.main.Home): if wired == 'up': network = 'Ethernet' elif ssid: - if self.get_ip_iotbox() == '10.11.12.1': + if access_point(): network = 'Wifi access point' else: network = 'Wifi : ' + ssid @@ -105,70 +98,33 @@ class IoTboxHomepage(odoo.addons.web.controllers.main.Home): 'message': ' '.join(pos_device[status]['messages']) }) - hdmi_name = subprocess.check_output('tvservice -n', shell=True).decode('utf-8') - if hdmi_name.find('=') != -1: - hdmi_name = hdmi_name.split('=')[1] - hdmi_message = subprocess.check_output('tvservice -s', shell=True).decode('utf-8') + for device in iot_devices: iot_device.append({ - 'name': 'display : ' + hdmi_name, - 'type': 'device', - 'message': hdmi_message - }) - - try: - f = open('/tmp/devices', 'r') - for line in f: - url = 'http://' + self.get_ip_iotbox() + ':8069/hw_drivers/driverdetails/' + line.split('|')[0] - urllib3.disable_warnings() - http = urllib3.PoolManager(cert_reqs='CERT_NONE') - value = '' - try: - req = http.request('GET', url) - except Exception as e: - req = '' - _logger.warning('Could not reach configured server') - _logger.error('A error encountered : %s ' % e) - - if req: - value = req.data.decode('utf-8') - iot_device.append({ - 'name': line.split('|')[1], - 'message': line.split('|')[0] + ' : ' + value, - 'type': 'device', - }) - f.close() - except: - pass - - try: - f = open('/tmp/printers', 'r') - for line in f: - iot_device.append({ - 'name': line, - 'type': 'printer', - }) - f.close() - except: - pass + 'name': iot_devices[device].device_name + ' : ' + str(iot_devices[device].data['value']), + 'type': iot_devices[device].device_type, + 'message': iot_devices[device].device_identifier + iot_devices[device].get_message() + }) return { 'hostname': hostname, - 'ip': self.get_ip_iotbox(), + 'ip': get_ip(), 'mac': ":".join(i + next(h) for i in h), 'iot_device_status': iot_device, - 'server_status': self.get_server_status() or 'Not Configured', + 'server_status': get_odoo_server_url() or 'Not Configured', 'network_status': network, } @http.route('/', type='http', auth='none') def index(self): - if (os.path.isfile('/home/pi/wifi_network.txt') == False or os.path.isfile('/home/pi/odoo-remote-server.conf') == False) and self.get_ip_iotbox() == '10.11.12.1': + wifi = Path.home() / 'wifi_network.txt' + remote_server = Path.home() / 'odoo-remote-server.conf' + if (wifi.exists() == False or remote_server.exists() == False) and access_point(): return configure_wizard_template.render({ 'title': 'Configure IoT Box', 'breadcrumb': 'Configure IoT Box', 'loading_message': 'Configuring your IoT Box', 'ssid': self.get_wifi_essid(), - 'server': self.get_server_status(), + 'server': get_odoo_server_url(), 'hostname': subprocess.check_output('hostname').decode('utf-8'), }) else: @@ -177,7 +133,7 @@ class IoTboxHomepage(odoo.addons.web.controllers.main.Home): @http.route('/list_drivers', type='http', auth='none', website=True) def list_drivers(self): drivers_list = [] - for driver in os.listdir("/home/pi/odoo/addons/hw_drivers/drivers"): + for driver in os.listdir(get_resource_path('hw_drivers', 'drivers')): if driver != '__pycache__': drivers_list.append(driver) return driver_list_template.render({ @@ -188,13 +144,13 @@ class IoTboxHomepage(odoo.addons.web.controllers.main.Home): @http.route('/load_drivers', type='http', auth='none', website=True) def load_drivers(self): - subprocess.call("sudo mount -o remount,rw /", shell=True) - subprocess.call("sudo mount -o remount,rw /root_bypass_ramdisks", shell=True) + subprocess.check_call("sudo mount -o remount,rw /", shell=True) + subprocess.check_call("sudo mount -o remount,rw /root_bypass_ramdisks", shell=True) mac = subprocess.check_output("/sbin/ifconfig eth0 |grep -Eo ..\(\:..\){5}", shell=True).decode('utf-8').split('\n')[0] #response = requests.get(url, auth=(username, db_uuid.split('\n')[0]), stream=True) - server = self.get_server_status() + server = get_odoo_server_url() if server: urllib3.disable_warnings() pm = urllib3.PoolManager(cert_reqs='CERT_NONE') @@ -205,17 +161,16 @@ class IoTboxHomepage(odoo.addons.web.controllers.main.Home): server, fields={'mac': mac}) except Exception as e: - req = '' - _logger.warning('Could not reach configured server') + _logger.error('Could not reach configured server') _logger.error('A error encountered : %s ' % e) if resp and resp.data: zip_file = zipfile.ZipFile(io.BytesIO(resp.data)) - zip_file.extractall("/home/pi/odoo/addons/hw_drivers/drivers") - subprocess.call("sudo service odoo restart", shell=True) - subprocess.call("sudo mount -o remount,ro /", shell=True) - subprocess.call("sudo mount -o remount,ro /root_bypass_ramdisks", shell=True) + zip_file.extractall(get_resource_path('hw_drivers', 'drivers')) + subprocess.check_call("sudo service odoo restart", shell=True) + subprocess.check_call("sudo mount -o remount,ro /", shell=True) + subprocess.check_call("sudo mount -o remount,ro /root_bypass_ramdisks", shell=True) - return "<meta http-equiv='refresh' content='20; url=http://" + self.get_ip_iotbox() + ":8069/list_drivers'>" + return "<meta http-equiv='refresh' content='20; url=http://" + get_ip() + ":8069/list_drivers'>" def get_wifi_essid(self): wifi_options = [] @@ -247,8 +202,8 @@ class IoTboxHomepage(odoo.addons.web.controllers.main.Home): else: persistent = "" - subprocess.call(['/home/pi/odoo/addons/point_of_sale/tools/posbox/configuration/connect_to_wifi.sh', essid, password, persistent]) - server = self.get_server_status() + subprocess.check_call([get_resource_path('point_of_sale', 'tools/posbox/configuration/connect_to_wifi.sh'), essid, password, persistent]) + server = get_odoo_server_url() res_payload = { 'message': 'Connecting to ' + essid, } @@ -262,29 +217,29 @@ class IoTboxHomepage(odoo.addons.web.controllers.main.Home): @http.route('/wifi_clear', type='http', auth='none', cors='*', csrf=False) def clear_wifi_configuration(self): - os.system('/home/pi/odoo/addons/point_of_sale/tools/posbox/configuration/clear_wifi_configuration.sh') + os.system(get_resource_path('point_of_sale', 'tools/posbox/configuration/clear_wifi_configuration.sh')) - return "<meta http-equiv='refresh' content='0; url=http://" + self.get_ip_iotbox() + ":8069'>" + return "<meta http-equiv='refresh' content='0; url=http://" + get_ip() + ":8069'>" @http.route('/server_clear', type='http', auth='none', cors='*', csrf=False) def clear_server_configuration(self): - os.system('/home/pi/odoo/addons/point_of_sale/tools/posbox/configuration/clear_server_configuration.sh') + os.system(get_resource_path('point_of_sale', 'tools/posbox/configuration/clear_server_configuration.sh')) - return "<meta http-equiv='refresh' content='0; url=http://" + self.get_ip_iotbox() + ":8069'>" + return "<meta http-equiv='refresh' content='0; url=http://" + get_ip() + ":8069'>" @http.route('/drivers_clear', type='http', auth='none', cors='*', csrf=False) def clear_drivers_list(self): - os.system('/home/pi/odoo/addons/point_of_sale/tools/posbox/configuration/clear_drivers_list.sh') + os.system(get_resource_path('point_of_sale', 'tools/posbox/configuration/clear_drivers_list.sh')) - return "<meta http-equiv='refresh' content='0; url=http://" + self.get_ip_iotbox() + ":8069/list_drivers'>" + return "<meta http-equiv='refresh' content='0; url=http://" + get_ip() + ":8069/list_drivers'>" @http.route('/server_connect', type='http', auth='none', cors='*', csrf=False) def connect_to_server(self, token, iotname): url = token.split('|')[0] token = token.split('|')[1] reboot = 'reboot' - subprocess.call(['/home/pi/odoo/addons/point_of_sale/tools/posbox/configuration/connect_to_server.sh', url, iotname, token, reboot]) - return 'http://' + self.get_ip_iotbox() + ':8069' + subprocess.check_call([get_resource_path('point_of_sale', 'tools/posbox/configuration/connect_to_server.sh'), url, iotname, token, reboot]) + return 'http://' + get_ip() + ':8069' @http.route('/steps', type='http', auth='none', cors='*', csrf=False) def step_by_step_configure_page(self): @@ -293,7 +248,7 @@ class IoTboxHomepage(odoo.addons.web.controllers.main.Home): 'breadcrumb': 'Configure IoT Box', 'loading_message': 'Configuring your IoT Box', 'ssid': self.get_wifi_essid(), - 'server': self.get_server_status(), + 'server': get_odoo_server_url(), 'hostname': subprocess.check_output('hostname').decode('utf-8').strip('\n'), }) @@ -304,7 +259,7 @@ class IoTboxHomepage(odoo.addons.web.controllers.main.Home): token = token.split('|')[1] else: url = '' - subprocess.call(['/home/pi/odoo/addons/point_of_sale/tools/posbox/configuration/connect_to_server_wifi.sh',url, iotname, token, essid, password, persistent]) + subprocess.check_call([get_resource_path('point_of_sale', 'tools/posbox/configuration/connect_to_server_wifi.sh'), url, iotname, token, essid, password, persistent]) return url # Set server address @@ -314,12 +269,19 @@ class IoTboxHomepage(odoo.addons.web.controllers.main.Home): 'title': 'IoT -> Odoo server configuration', 'breadcrumb': 'Configure Odoo Server', 'hostname': subprocess.check_output('hostname').decode('utf-8').strip('\n'), - 'server_status': self.get_server_status() or 'Not configured yet', + 'server_status': get_odoo_server_url() or 'Not configured yet', 'loading_message': 'Configure Domain Server' }) @http.route('/remote_connect', type='http', auth='none', cors='*') def remote_connect(self): + """ + Establish a link with a customer box trough internet with a ssh tunnel + 1 - take a new auth_token on https://dashboard.ngrok.com/ + 2 - copy past this auth_token on the IoT Box : http://IoT_Box:8069/remote_connect + 3 - check on ngrok the port and url to get access to the box + 4 - you can connect to the box with this command : ssh -p port -v pi@url + """ return remote_connect_template.render({ 'title': 'Remote debugging', 'breadcrumb': 'Remote Debugging', diff --git a/addons/hw_posbox_homepage/views/homepage.html b/addons/hw_posbox_homepage/views/homepage.html index 71c0f26c565..b14573e9805 100644 --- a/addons/hw_posbox_homepage/views/homepage.html +++ b/addons/hw_posbox_homepage/views/homepage.html @@ -28,6 +28,7 @@ } .device-status .message { font-size: 0.8rem; + max-width: 350px; } .device-status .indicator { margin-left: 4px; @@ -90,7 +91,7 @@ </tr> <tr> <td class="heading">Version</td> - <td>V18.12 <a class="btn btn-sm float-right" href='/hw_proxy/upgrade/'>update</a></td> + <td>V19.04 <a class="btn btn-sm float-right" href='/hw_proxy/upgrade/'>update</a></td> </tr> <tr> <td class="heading">IP Address</td> diff --git a/addons/hw_posbox_homepage/views/remote_connect.html b/addons/hw_posbox_homepage/views/remote_connect.html index e83ced4653c..ab9628ec36a 100644 --- a/addons/hw_posbox_homepage/views/remote_connect.html +++ b/addons/hw_posbox_homepage/views/remote_connect.html @@ -31,7 +31,7 @@ {% block content %} <h2 class="text-center">Remote Debugging</h2> <p class='text-red'> - This allows someone to gain remote access to your IoT Box, and + This allows someone who give a ngrok authtoken to gain remote access to your IoT Box, and thus your entire local network. Only enable this for someone you trust. </p> diff --git a/addons/l10n_be/__manifest__.py b/addons/l10n_be/__manifest__.py index 975cb4cccfa..2499591a89e 100644 --- a/addons/l10n_be/__manifest__.py +++ b/addons/l10n_be/__manifest__.py @@ -21,16 +21,16 @@ Wizards provided by this module: -------------------------------- * Partner VAT Intra: Enlist the partners with their related VAT and invoiced amounts. Prepares an XML file format. - + **Path to access :** Invoicing/Reporting/Legal Reports/Belgium Statements/Partner VAT Intra * Periodical VAT Declaration: Prepares an XML file for Vat Declaration of the Main company of the User currently Logged in. - + **Path to access :** Invoicing/Reporting/Legal Reports/Belgium Statements/Periodical VAT Declaration * Annual Listing Of VAT-Subjected Customers: Prepares an XML file for Vat Declaration of the Main company of the User currently Logged in Based on Fiscal year. - + **Path to access :** Invoicing/Reporting/Legal Reports/Belgium Statements/Annual Listing Of VAT-Subjected Customers """, diff --git a/addons/l10n_be_edi/__init__.py b/addons/l10n_be_edi/__init__.py new file mode 100644 index 00000000000..dc5e6b693d1 --- /dev/null +++ b/addons/l10n_be_edi/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models diff --git a/addons/l10n_be_edi/__manifest__.py b/addons/l10n_be_edi/__manifest__.py new file mode 100644 index 00000000000..eb4be7f9998 --- /dev/null +++ b/addons/l10n_be_edi/__manifest__.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +{ + 'name': 'Belgium - E-Invoicing (UBL 2.0, e-fff)', + 'version': '0.1', + 'category': 'Hidden', + 'summary': 'E-Invoicing, Universal Business Language (UBL 2.0), e-fff protocol', + 'description': """ +Universal Business Language (UBL <http://ubl.xml.org/>`_) is a library of standard electronic XML business documents such as +invoices. The UBL standard became the `ISO/IEC 19845 +<http://www.iso.org/iso/catalogue_detail.htm?csnumber=66370>`_ standard in January 2016 +(cf the `official announce <http://www.prweb.com/releases/2016/01/prweb13186919.htm>`_). +Belgian e-invoicing uses the UBL 2.0 using the e-fff protocol. + """, + 'depends': ['account', 'l10n_be', 'account_facturx'], + 'data': [ + ], + 'installable': True, + 'auto_install': True, +} diff --git a/addons/l10n_be_edi/models/__init__.py b/addons/l10n_be_edi/models/__init__.py new file mode 100644 index 00000000000..ab922af0d1e --- /dev/null +++ b/addons/l10n_be_edi/models/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import account_invoice diff --git a/addons/l10n_be_edi/models/account_invoice.py b/addons/l10n_be_edi/models/account_invoice.py new file mode 100644 index 00000000000..55dc27bf418 --- /dev/null +++ b/addons/l10n_be_edi/models/account_invoice.py @@ -0,0 +1,178 @@ +# -*- coding: utf-8 -*- + +from odoo import api, fields, models, tools, _ +from odoo.exceptions import UserError +from odoo.tests.common import Form +from odoo.osv import expression +import base64 +from io import BytesIO + +import logging +_logger = logging.getLogger(__name__) + + +class AccountInvoice(models.Model): + _inherit = 'account.invoice' + + @api.model + def _get_ubl_namespaces(self, tree): + ''' If the namespace is declared with xmlns='...', the namespaces map contains the 'None' key that causes an + TypeError: empty namespace prefix is not supported in XPath + Then, we need to remap arbitrarily this key. + + :param tree: An instance of etree. + :return: The namespaces map without 'None' key. + ''' + namespaces = tree.nsmap + namespaces['inv'] = namespaces.pop(None) + return namespaces + + @api.model + def _detect_ubl_2_1(self, tree): + # Quick check the tree looks like an UBL 2.1 file. + flag = tree.tag == '{urn:oasis:names:specification:ubl:schema:xsd:Invoice-2}Invoice' + error = None + + return {'flag': flag, 'error': error} + + @api.model + def _decode_ubl_2_1(self, tree): + self.ensure_one() + namespaces = self._get_ubl_namespaces(tree) + + journal_id = self._default_journal() + view = journal_id.type == 'purchase' and 'account.invoice_supplier_form' or 'account.invoice_form' + with Form(self, view=view) as invoice_form: + # Reference + elements = tree.xpath('//cbc:ID', namespaces=namespaces) + if elements: + invoice_form.reference = elements[0].text + elements = tree.xpath('//cbc:InstructionID', namespaces=namespaces) + if elements: + invoice_form.name = elements[0].text + + # Dates + elements = tree.xpath('//cbc:IssueDate', namespaces=namespaces) + if elements: + invoice_form.date_invoice = elements[0].text + elements = tree.xpath('//cbc:PaymentDueDate', namespaces=namespaces) + if elements: + invoice_form.date_due = elements[0].text + # allow both cbc:PaymentDueDate and cbc:DueDate + elements = tree.xpath('//cbc:DueDate', namespaces=namespaces) + invoice_form.date_due = invoice_form.date_due or elements and elements[0].text + + # Type + elements = tree.xpath('//cbc:InvoiceTypeCode', namespaces=namespaces) + if elements: + type_code = elements[0].text + invoice_form.type = 'in_refund' if type_code == '381' else 'in_invoice' + + # Currency + elements = tree.xpath('//cbc:DocumentCurrencyCode', namespaces=namespaces) + currency_code = elements and elements[0].text or '' + currency = self.env['res.currency'].search([('name', '=', currency_code.upper())], limit=1) + if elements: + invoice_form.currency_id = currency + + # Incoterm + elements = tree.xpath('//cbc:TransportExecutionTerms/cac:DeliveryTerms/cbc:ID', namespaces=namespaces) + if elements: + invoice_form.incoterm_id = self.env['account.incoterms'].search([('code', '=', elements[0].text)]) + + # Partner + partner_element = tree.xpath('//cac:AccountingSupplierParty/cac:Party', namespaces=namespaces) + if partner_element: + domains = [] + partner_element = partner_element[0] + elements = partner_element.xpath('//cac:AccountingSupplierParty/cac:Party//cbc:Name', namespaces=namespaces) + if elements: + partner_name = elements[0].text + domains.append([('name', 'ilike', partner_name)]) + elements = partner_element.xpath('//cac:AccountingSupplierParty/cac:Party//cbc:Telephone', namespaces=namespaces) + if elements: + partner_telephone = elements[0].text + domains.append([('phone', '=', partner_telephone), ('mobile', '=', partner_telephone)]) + elements = partner_element.xpath('//cac:AccountingSupplierParty/cac:Party//cbc:ElectronicMail', namespaces=namespaces) + if elements: + partner_mail = elements[0].text + domains.append([('email', '=', partner_mail)]) + elements = partner_element.xpath('//cac:AccountingSupplierParty/cac:Party//cbc:ID', namespaces=namespaces) + if elements: + partner_id = elements[0].text + domains.append([('vat', 'like', partner_id)]) + + if domains: + partner = self.env['res.partner'].search(expression.OR(domains), limit=1) + if partner: + invoice_form.partner_id = partner + else: + invoice_form.partner_id = self.env['res.partner'] + + # Regenerate PDF + elements = tree.xpath('//cac:AdditionalDocumentReference', namespaces=namespaces) + if elements: + attachment_name = elements[0].xpath('cbc:ID', namespaces=namespaces)[0].text + attachment_data = elements[0].xpath('cac:Attachment//cbc:EmbeddedDocumentBinaryObject', namespaces=namespaces)[0].text + attachment_id = self.env['ir.attachment'].create({ + 'name': attachment_name, + 'res_id': self.id, + 'res_model': 'account.invoice', + 'datas': attachment_data, + 'datas_fname': attachment_name, + 'type': 'binary', + }) + self.with_context(no_new_invoice=True).message_post(attachment_ids=[attachment_id.id]) + + # Lines + lines_elements = tree.xpath('//cac:InvoiceLine', namespaces=namespaces) + for eline in lines_elements: + with invoice_form.invoice_line_ids.new() as invoice_line_form: + # Quantity + elements = eline.xpath('cbc:InvoicedQuantity', namespaces=namespaces) + quantity = elements and float(elements[0].text) or 1.0 + invoice_line_form.quantity = quantity + + # Price Unit + elements = eline.xpath('cac:Price/cbc:PriceAmount', namespaces=namespaces) + invoice_line_form.price_unit = elements and float(elements[0].text) or 0.0 + + # Name + elements = eline.xpath('cac:Item/cbc:Description', namespaces=namespaces) + invoice_line_form.name = elements and elements[0].text or '' + invoice_line_form.name = invoice_line_form.name.replace('%month%', str(fields.Date.to_date(invoice_form.date_invoice).month)) # TODO: full name in locale + invoice_line_form.name = invoice_line_form.name.replace('%year%', str(fields.Date.to_date(invoice_form.date_invoice).year)) + + # Product + elements = eline.xpath('cac:Item/cac:SellersItemIdentification/cbc:ID', namespaces=namespaces) + domains = [] + if elements: + product_code = elements[0].text + domains.append([('default_code', '=', product_code)]) + elements = eline.xpath('cac:Item/cac:StandardItemIdentification/cbc:ID[@schemeID=\'GTIN\']', namespaces=namespaces) + if elements: + product_ean13 = elements[0].text + domains.append([('ean13', '=', product_ean13)]) + if domains: + product = self.env['product.product'].search(expression.OR(domains), limit=1) + if product: + invoice_line_form.product_id = product + + # Taxes + taxes_elements = eline.xpath('cac:TaxTotal/cac:TaxSubtotal', namespaces=namespaces) + invoice_line_form.invoice_line_tax_ids.clear() + for etax in taxes_elements: + elements = etax.xpath('cbc:Percent', namespaces=namespaces) + if elements: + tax = self.env['account.tax'].search([('amount', '=', float(elements[0].text)), ('type_tax_use', '=', journal_id.type)], order='sequence ASC', limit=1) + if tax: + invoice_line_form.invoice_line_tax_ids.add(tax) + invoice_line_form.account_id = invoice_line_form.account_id or self.env['account.account'].browse(self.with_context(journal_id=journal_id.id).env['account.invoice.line']._default_account()) + + return invoice_form.save + + @api.model + def _get_xml_decoders(self): + # Override + ubl_decoders = [('UBL 2.1', self._detect_ubl_2_1, self._decode_ubl_2_1)] + return super(AccountInvoice, self)._get_xml_decoders() + ubl_decoders diff --git a/addons/l10n_be_edi/test_xml_file/efff_test.xml b/addons/l10n_be_edi/test_xml_file/efff_test.xml new file mode 100644 index 00000000000..625bc32737e --- /dev/null +++ b/addons/l10n_be_edi/test_xml_file/efff_test.xml @@ -0,0 +1,118 @@ +<?xml version="1.0" encoding="UTF-8"?>
+<Invoice xmlns:qdt="urn:oasis:names:specification:ubl:schema:xsd:QualifiedDatatypes-2" xmlns:ccts="urn:oasis:names:specification:ubl:schema:xsd:CoreComponentParameters-2" xmlns:stat="urn:oasis:names:specification:ubl:schema:xsd:DocumentStatusCode-1.0" xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2" xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" xmlns:udt="urn:un:unece:uncefact:data:draft:UnqualifiedDataTypesSchemaModule:2" xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"> + <cbc:UBLVersionID>2.0</cbc:UBLVersionID> + <cbc:CustomizationID>1.0</cbc:CustomizationID> + <cbc:ProfileID>FFF.BE KOALABOOX 1.0.0</cbc:ProfileID> + <cbc:ID>2019/045</cbc:ID> + <cbc:CopyIndicator>0</cbc:CopyIndicator> + <cbc:IssueDate>2019-01-31</cbc:IssueDate> + <cbc:InvoiceTypeCode listURI="http://www.FFF.be/ubl/2.0/cl/gc/BE-InvoiceCode-1.0.gc">380</cbc:InvoiceTypeCode> + <cbc:Note></cbc:Note> + <cbc:TaxPointDate>2019-01-31</cbc:TaxPointDate> + <cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode> + <cbc:LineCountNumeric>1</cbc:LineCountNumeric> + <cac:AccountingSupplierParty> + <cac:Party> + <cbc:EndpointID schemeID="GLN">BE0123456789</cbc:EndpointID> + <cac:PartyIdentification> + <cbc:ID schemeAgencyName="KBO" schemeAgencyID="BE">123456789</cbc:ID> + </cac:PartyIdentification> + <cac:PartyName> + <cbc:Name>The best supplier</cbc:Name> + </cac:PartyName> + <cac:PostalAddress> + <cbc:StreetName>Chemin de Odoo 34</cbc:StreetName> + <cbc:CityName>Odoo Ville</cbc:CityName> + <cbc:PostalZone>7777</cbc:PostalZone> + <cac:Country> + <cbc:IdentificationCode>BE</cbc:IdentificationCode> + </cac:Country> + </cac:PostalAddress> + <cac:PartyLegalEntity> + <cbc:RegistrationName>The best supplier</cbc:RegistrationName> + <cbc:CompanyID>12345689</cbc:CompanyID> + </cac:PartyLegalEntity> + </cac:Party> + </cac:AccountingSupplierParty> + <cac:AccountingCustomerParty> + <cbc:CustomerAssignedAccountID></cbc:CustomerAssignedAccountID> + <cac:Party> + <cac:PartyIdentification> + <cbc:ID schemeAgencyName="Just a customer" schemeAgencyID=""/> + </cac:PartyIdentification> + <cac:PartyName> + <cbc:Name>Just a customer</cbc:Name> + </cac:PartyName> + <cac:PostalAddress> + <cbc:StreetName>Customer Street 5 </cbc:StreetName> + <cbc:CityName>Customer City</cbc:CityName> + <cbc:PostalZone>6666</cbc:PostalZone> + <cac:Country> + <cbc:IdentificationCode>BE</cbc:IdentificationCode> + </cac:Country> + </cac:PostalAddress> + <cac:Contact> + <cbc:Name>Just a customer</cbc:Name> + <cbc:Telephone></cbc:Telephone> + <cbc:ElectronicMail/> + </cac:Contact> + </cac:Party> + </cac:AccountingCustomerParty> + <cac:PaymentMeans> + <cbc:PaymentMeansCode listURI="http://docs.oasis-open.org/ubl/os-UBL-2.0-update/cl/gc/default/PaymentMeansCode-2.0.gc" listName="Payment Means" listID="UN/ECE 4461">1</cbc:PaymentMeansCode> + <cbc:PaymentDueDate>2019-02-07</cbc:PaymentDueDate> + <cbc:InstructionID>+++000/0000/00505+++</cbc:InstructionID> + <cac:PayeeFinancialAccount> + <cbc:ID></cbc:ID> + </cac:PayeeFinancialAccount> + </cac:PaymentMeans> + <cac:TaxTotal> + <cbc:TaxAmount currencyID="EUR">115.67</cbc:TaxAmount> + <cac:TaxSubtotal> + <cbc:TaxableAmount currencyID="EUR">550.83</cbc:TaxableAmount> + <cbc:TaxAmount currencyID="EUR">115.67</cbc:TaxAmount> + <cbc:Percent>21.00</cbc:Percent> + <cac:TaxCategory> + <cbc:ID schemeName="Duty or tax or fee category" schemeID="UN/EDIFACT 5305">S</cbc:ID> + <cbc:Name>03</cbc:Name> + <cac:TaxScheme> + <cbc:ID>VAT</cbc:ID> + </cac:TaxScheme> + </cac:TaxCategory> + </cac:TaxSubtotal> + </cac:TaxTotal> + <cac:LegalMonetaryTotal> + <cbc:LineExtensionAmount currencyID="EUR">550.83</cbc:LineExtensionAmount> + <cbc:TaxExclusiveAmount currencyID="EUR">550.83</cbc:TaxExclusiveAmount> + <cbc:TaxInclusiveAmount currencyID="EUR">666.50</cbc:TaxInclusiveAmount> + <cbc:PayableAmount currencyID="EUR">666.50</cbc:PayableAmount> + </cac:LegalMonetaryTotal> + <cac:InvoiceLine> + <cbc:ID>1</cbc:ID> + <cbc:Note></cbc:Note> + <cbc:InvoicedQuantity>1.0000</cbc:InvoicedQuantity> + <cbc:LineExtensionAmount currencyID="EUR">550.83</cbc:LineExtensionAmount> + <cac:TaxTotal> + <cbc:TaxAmount currencyID="EUR">115.67</cbc:TaxAmount> + <cac:TaxSubtotal> + <cbc:TaxableAmount currencyID="EUR">550.83</cbc:TaxableAmount> + <cbc:TaxAmount currencyID="EUR">115.67</cbc:TaxAmount> + <cbc:Percent>21.00</cbc:Percent> + <cac:TaxCategory> + <cbc:ID schemeName="Duty or tax or fee category" schemeID="UN/EDIFACT 5305">S</cbc:ID> + <cbc:Name>03</cbc:Name> + <cac:TaxScheme> + <cbc:ID>VAT</cbc:ID> + </cac:TaxScheme> + </cac:TaxCategory> + </cac:TaxSubtotal> + </cac:TaxTotal> + <cac:Item> + <cbc:Description>Loyer du %month% / %year%</cbc:Description> + <cbc:Name></cbc:Name> + </cac:Item> + <cac:Price> + <cbc:PriceAmount currencyID="EUR">550.83</cbc:PriceAmount> + </cac:Price> + </cac:InvoiceLine> +</Invoice> diff --git a/addons/l10n_be_edi/tests/__init__.py b/addons/l10n_be_edi/tests/__init__.py new file mode 100644 index 00000000000..e7d057babc7 --- /dev/null +++ b/addons/l10n_be_edi/tests/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import test_ubl
\ No newline at end of file diff --git a/addons/l10n_be_edi/tests/test_ubl.py b/addons/l10n_be_edi/tests/test_ubl.py new file mode 100644 index 00000000000..83330dec590 --- /dev/null +++ b/addons/l10n_be_edi/tests/test_ubl.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +from odoo import tools, _ +from odoo.tests import common +import base64 +from odoo.modules.module import get_module_resource + +class TestUBL(common.TransactionCase): + def setUp(self): + super(TestUBL, self).setUp() + # Force user Belgium country. + self.env.user.company_id = self.env['res.company'].create({'name': 'MyCompany'}) + self.env.user.company_id.country = self.env.ref('base.be') + self.env.ref('l10n_be.l10nbe_chart_template').load_for_current_company(False, False) + self.partner_id = self.env['res.partner'].create({'name': 'TestUser', 'vat': 'BE0123456789'}) + + def test_ubl_invoice_import(self): + xml_file_path = get_module_resource('l10n_be_edi', 'test_xml_file', 'efff_test.xml') + xml_file = open(xml_file_path, 'rb').read() + invoice_id = self.env['account.invoice'].with_context(default_type='in_invoice').create({}) + + attachment_id = self.env['ir.attachment'].create({ + 'name': 'efff_test.xml', + 'datas_fname': 'efff_test.xml', + 'datas': base64.encodestring(xml_file), + 'res_id': invoice_id.id, + 'res_model': 'account.invoice', + }) + + invoice_id.message_post(attachment_ids=[attachment_id.id]) + + self.assertEqual(invoice_id.amount_total, 666.50) + self.assertEqual(invoice_id.amount_tax, 115.67) + self.assertEqual(invoice_id.partner_id, self.partner_id) diff --git a/addons/l10n_generic_coa/data/account_invoice_demo.xml b/addons/l10n_generic_coa/data/account_invoice_demo.xml index 90ec3240e54..667c2445054 100644 --- a/addons/l10n_generic_coa/data/account_invoice_demo.xml +++ b/addons/l10n_generic_coa/data/account_invoice_demo.xml @@ -191,10 +191,10 @@ ('user_type_id', '=', ref('account.data_account_type_expenses')), ('company_id', '=', obj().env['res.company']._company_default_get('account.account').id)]"/> <field name="invoice_line_tax_ids" model="account.tax" - eval="[(6, 0, [obj().search([ + eval="[(6, 0, obj().search([ ('type_tax_use', '=', 'purchase'), ('amount', '>', 0), - ('company_id', '=', obj().env['res.company']._company_default_get('account.tax').id)], limit=1).id])]"/> + ('company_id', '=', obj().env['res.company']._company_default_get('account.tax').id)], limit=1).ids)]"/> </record> <function model="account.invoice" name="_onchange_invoice_line_ids"> diff --git a/addons/l10n_in/views/report_invoice.xml b/addons/l10n_in/views/report_invoice.xml index b71c2b5cfea..52bd759dafc 100644 --- a/addons/l10n_in/views/report_invoice.xml +++ b/addons/l10n_in/views/report_invoice.xml @@ -28,6 +28,7 @@ <xpath expr="//table[@name='invoice_line_table']/thead/tr" position="inside"> <t t-if="o.company_id.country_id.code == 'IN'"> + <th>HSN/SAC<t t-set="colspan" t-value="colspan+1"/></th> <th class="text-right" t-if="is_cgst">CGST<t t-set="colspan" t-value="colspan+1"/></th> <th class="text-right" t-if="is_cgst">SGST<t t-set="colspan" t-value="colspan+1"/></th> <th class="text-right" t-if="is_igst">IGST<t t-set="colspan" t-value="colspan+1"/></th> @@ -35,11 +36,10 @@ </t> </xpath> - <xpath expr="//td[@name='account_invoice_line_name']" position="after"> - <t t-if="line.product_id.l10n_in_hsn_code"><h6><strong class="ml16">HSN/SAC Code:</strong> <span t-field="line.product_id.l10n_in_hsn_code"/></h6></t> - </xpath> - <xpath expr="//t[@name='account_invoice_line_accountable']" position="inside"> + <td> + <span t-if="line.product_id.l10n_in_hsn_code" t-field="line.product_id.l10n_in_hsn_code"></span> + </td> <t t-if="o.company_id.country_id.code == 'IN'"> <td class="text-right" t-if="is_cgst"> <t t-foreach="tax_datas[line.id]" t-as="tax_data"> diff --git a/addons/l10n_jp/data/account.tax.template.csv b/addons/l10n_jp/data/account.tax.template.csv index 1b8e083b42d..a9ef3e9ae8e 100644 --- a/addons/l10n_jp/data/account.tax.template.csv +++ b/addons/l10n_jp/data/account.tax.template.csv @@ -1,6 +1,6 @@ id,name,description,chart_template_id/id,type_tax_use,amount_type,amount,price_include,sequence,include_base_amount,account_id/id,refund_account_id/id,tag_ids/id,tax_group_id/id -tax_in_e,仮受消費税(外),仮受消費税(外),l10n_jp1,sale,percent,8,FALSE,1,FALSE,A21807,A21807,"tag_jp_03,tag_jp_12",tax_group_8 -tax_in_i,仮受消費税(内),仮受消費税(内),l10n_jp1,sale,percent,8,TRUE,1,FALSE,A21807,A21807,"tag_jp_03,tag_jp_12",tax_group_8 +tax_in_e,仮受消費税(外),仮受消費税(外),l10n_jp1,sale,percent,8,FALSE,1,FALSE,A21809,A21809,"tag_jp_03,tag_jp_12",tax_group_8 +tax_in_i,仮受消費税(内),仮受消費税(内),l10n_jp1,sale,percent,8,TRUE,1,FALSE,A21809,A21809,"tag_jp_03,tag_jp_12",tax_group_8 tax_in_x,輸出免税,輸出免税,l10n_jp1,sale,percent,,FALSE,1,FALSE,,,"tag_jp_04,tag_jp_13",tax_group_0 tax_in_o,非課税販売,非課税販売,l10n_jp1,sale,percent,,FALSE,1,FALSE,,,"tag_jp_09,tag_jp_14",tax_group_0 tax_out_e,仮払消費税(外),仮払消費税(外),l10n_jp1,purchase,percent,8,FALSE,1,FALSE,A11807,A11807,"tag_jp_07,tag_jp_16",tax_group_8 diff --git a/addons/l10n_multilang/models/l10n_multilang.py b/addons/l10n_multilang/models/l10n_multilang.py index 08ecb01a665..2f6e3731faa 100644 --- a/addons/l10n_multilang/models/l10n_multilang.py +++ b/addons/l10n_multilang/models/l10n_multilang.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from collections import defaultdict import logging from odoo import api, models @@ -88,20 +89,33 @@ class AccountChartTemplate(models.Model): def _get_template_from_model(self, company_id, model): """ Find the records and their matching template """ - # genereated records have an external id with the format <company id>_<template id> - out_data = self.env['ir.model.data'].search([('model', '=', model), ('name', '=like', str(company_id)+'\_%')]) - out_ids = self.env[model].browse(out_data.mapped('res_id')) - - # templates and records may have been created in a different order - # reorder them based on external id names - in_xml_id_names = [xml_id.partition(str(company_id) + '_')[-1] for xml_id in out_data.mapped('name')] - in_xml_id_names = {name: index for index, name in enumerate(in_xml_id_names)} - - in_xml_ids = self.env['ir.model.data'].search([('model', '=', model+'.template'), ('name', 'in', list(in_xml_id_names))]) - in_xml_ids = in_xml_ids.sorted(key=lambda x: in_xml_id_names[x.name]) - - in_ids = self.env[model+'.template'].browse(in_xml_ids.mapped('res_id')) - return (in_ids, out_ids) + # generated records have an external id with the format <company id>_<template xml id> + grouped_out_data = defaultdict(lambda: self.env['ir.model.data']) + for imd in self.env['ir.model.data'].search([ + ('model', '=', model), + ('name', '=like', str(company_id) + '_%') + ]): + grouped_out_data[imd.module] += imd + + in_records = self.env[model + '.template'] + out_records = self.env[model] + for module, out_data in grouped_out_data.items(): + # templates and records may have been created in a different order + # reorder them based on external id names + expected_in_xml_id_names = {xml_id.name.partition(str(company_id) + '_')[-1]: xml_id for xml_id in out_data} + + in_xml_ids = self.env['ir.model.data'].search([ + ('model', '=', model + '.template'), + ('module', '=', module), + ('name', 'in', list(expected_in_xml_id_names)) + ]) + in_xml_ids = {xml_id.name: xml_id for xml_id in in_xml_ids} + + for name, xml_id in expected_in_xml_id_names.items(): + in_records += self.env[model + '.template'].browse(in_xml_ids[name].res_id) + out_records += self.env[model].browse(xml_id.res_id) + + return (in_records, out_records) class BaseLanguageInstall(models.TransientModel): """ Install Language""" diff --git a/addons/l10n_mx/data/account_tax_data.xml b/addons/l10n_mx/data/account_tax_data.xml index 12555ba9ed4..5bc9932a5a0 100644 --- a/addons/l10n_mx/data/account_tax_data.xml +++ b/addons/l10n_mx/data/account_tax_data.xml @@ -34,6 +34,10 @@ <field name="name">DIOT: 8%</field> <field name="applicability">taxes</field> </record> + <record id="tag_diot_8_non_cre" model="account.account.tag"> + <field name="name">DIOT: 8% NO ACREDITABLE</field> + <field name="applicability">taxes</field> + </record> <record id="tag_diot_ret" model="account.account.tag"> <field name="name">DIOT: Retención</field> <field name="applicability">taxes</field> diff --git a/addons/l10n_pl/data/account_tax_data.xml b/addons/l10n_pl/data/account_tax_data.xml index f29294b36aa..973d05ff7b6 100644 --- a/addons/l10n_pl/data/account_tax_data.xml +++ b/addons/l10n_pl/data/account_tax_data.xml @@ -256,7 +256,7 @@ <field name="type_tax_use">sale</field> <field name="refund_account_id" ref="chart22030200"/> <field name="account_id" ref="chart22030200"/> - <field name="tag_ids" eval="[ref('l10n_pl.tag_pl_08'), ref('l10n_pl.tag_pl_29')]"/> + <field name="tag_ids" eval="[(6,0,[ref('l10n_pl.tag_pl_08'), ref('l10n_pl.tag_pl_29')])]"/> <field name="tax_group_id" ref="tax_group_vat_7"/> </record> diff --git a/addons/mail/models/ir_attachment.py b/addons/mail/models/ir_attachment.py index 31e59d680c9..7273145096d 100644 --- a/addons/mail/models/ir_attachment.py +++ b/addons/mail/models/ir_attachment.py @@ -13,9 +13,9 @@ class IrAttachment(models.Model): """ super(IrAttachment, self)._post_add_create() for record in self: - record.register_as_main_attachment() + record.register_as_main_attachment(force=False) - def register_as_main_attachment(self): + def register_as_main_attachment(self, force=True): """ Registers this attachment as the main one of the model it is attached to. """ @@ -24,4 +24,5 @@ class IrAttachment(models.Model): # message_main_attachment_id field can be empty, that's why we compare to False; # we are just checking that it exists on the model before writing it if related_record and hasattr(related_record, 'message_main_attachment_id'): - related_record.message_main_attachment_id = self + if force or not related_record.message_main_attachment_id: + related_record.message_main_attachment_id = self diff --git a/addons/mail/models/mail_mail.py b/addons/mail/models/mail_mail.py index 7aff20cf59e..a2ba820656f 100644 --- a/addons/mail/models/mail_mail.py +++ b/addons/mail/models/mail_mail.py @@ -5,6 +5,7 @@ import base64 import datetime import logging import psycopg2 +import smtplib import threading import re @@ -162,14 +163,14 @@ class MailMail(models.Model): failed = self.env['mail.notification'] if failure_type: failed = notifications.filtered(lambda notif: notif.res_partner_id not in success_pids) - failed.write({ + failed.sudo().write({ 'email_status': 'exception', 'failure_type': failure_type, 'failure_reason': failure_reason, }) messages = notifications.mapped('mail_message_id').filtered(lambda m: m.res_id and m.model) messages._notify_failure_update() # notify user that we have a failure - (notifications - failed).write({ + (notifications - failed).sudo().write({ 'email_status': 'sent', }) if not failure_type or failure_type == 'RECIPIENT': # if we have another error, we want to keep the mail. @@ -338,7 +339,7 @@ class MailMail(models.Model): ]) if notifs: notif_msg = _('Error without exception. Probably due do concurrent access update of notification records. Please see with an administrator.') - notifs.write({ + notifs.sudo().write({ 'email_status': 'exception', 'failure_type': 'UNKNOWN', 'failure_reason': notif_msg, @@ -394,11 +395,12 @@ class MailMail(models.Model): mail.id, mail.message_id) # mail status will stay on ongoing since transaction will be rollback raise - except psycopg2.Error: - # If an error with the database occurs, chances are that the cursor is unusable. - # This will lead to an `psycopg2.InternalError` being raised when trying to write - # `state`, shadowing the original exception and forbid a retry on concurrent - # update. Let's bubble it. + except (psycopg2.Error, smtplib.SMTPServerDisconnected): + # If an error with the database or SMTP session occurs, chances are that the cursor + # or SMTP session are unusable, causing further errors when trying to save the state. + _logger.exception( + 'Exception while processing mail with ID %r and Msg-Id %r.', + mail.id, mail.message_id) raise except Exception as e: failure_reason = tools.ustr(e) diff --git a/addons/mail/models/mail_message.py b/addons/mail/models/mail_message.py index e228f7c4062..e2ebb16c69e 100644 --- a/addons/mail/models/mail_message.py +++ b/addons/mail/models/mail_message.py @@ -154,8 +154,8 @@ class Message(models.Model): @api.multi def _search_has_error(self, operator, operand): if operator == '=' and operand: - return ['&', ('notification_ids.email_status', 'in', ('bounce', 'exception')), ('author_id', '=', self.env.user.partner_id.id)] - return ['!', '&', ('notification_ids.email_status', 'in', ('bounce', 'exception')), ('author_id', '=', self.env.user.partner_id.id)] # this wont work and will be equivalent to "not in" beacause of orm restrictions. Dont use "has_error = False" + return [('notification_ids.email_status', 'in', ('bounce', 'exception'))] + return ['!', ('notification_ids.email_status', 'in', ('bounce', 'exception'))] # this wont work and will be equivalent to "not in" beacause of orm restrictions. Dont use "has_error = False" @api.depends('starred_partner_ids') def _get_starred(self): diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index 127b7f7d609..81f1dc0510b 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -237,7 +237,7 @@ class MailThread(models.AbstractModel): @api.model def _search_message_has_error(self, operator, operand): - return [('message_ids.has_error', operator, operand)] + return ['&', ('message_ids.has_error', operator, operand), ('message_ids.author_id', '=', self.env.user.partner_id.id)] @api.multi def _compute_message_attachment_count(self): diff --git a/addons/mail/static/src/js/chatter.js b/addons/mail/static/src/js/chatter.js index bc5d18a4150..968242ffe6a 100644 --- a/addons/mail/static/src/js/chatter.js +++ b/addons/mail/static/src/js/chatter.js @@ -81,6 +81,7 @@ var Chatter = Widget.extend({ var nodeOptions = fieldsInfo[mailFields.mail_thread].options || {}; this.hasLogButton = options.display_log_button || nodeOptions.display_log_button; this.postRefresh = nodeOptions.post_refresh || 'never'; + this.reloadOnUploadAttachment = this.postRefresh === 'always'; } }, /** @@ -577,6 +578,9 @@ var Chatter = Widget.extend({ * @private */ _onReloadAttachmentBox: function () { + if (this.reloadOnUploadAttachment) { + this.trigger_up('reload'); + } this._reloadAttachmentBox(); }, /** diff --git a/addons/mail/static/src/js/document_viewer.js b/addons/mail/static/src/js/document_viewer.js index fc084b48204..be7dfeb9115 100644 --- a/addons/mail/static/src/js/document_viewer.js +++ b/addons/mail/static/src/js/document_viewer.js @@ -44,11 +44,10 @@ var DocumentViewer = Widget.extend({ this._super.apply(this, arguments); this.attachment = _.filter(attachments, function (attachment) { var match = attachment.type === 'url' ? attachment.url.match("(youtu|.png|.jpg|.gif)") : attachment.mimetype.match("(image|video|application/pdf|text)"); - if (match) { - attachment.type = match[1]; + attachment.fileType = match[1]; if (match[1].match("(.png|.jpg|.gif)")) { - attachment.type = 'image'; + attachment.fileType = 'image'; } if (match[1] === 'youtu') { var youtube_array = attachment.url.split('/'); diff --git a/addons/mail/static/src/scss/chatter.scss b/addons/mail/static/src/scss/chatter.scss index b670e5b92d0..5b503966706 100644 --- a/addons/mail/static/src/scss/chatter.scss +++ b/addons/mail/static/src/scss/chatter.scss @@ -21,11 +21,11 @@ position: relative; float: right; flex: 1 1 auto; + height: $o-statusbar-height; .o_chatter_button_attachment { - height: $o-statusbar-height - 2px; + height: $o-statusbar-height; float: right; - padding: 0px; margin-right: 25px; .fa-paperclip, .o_chatter_attachment_button_count { @@ -33,15 +33,20 @@ } } .o_followers { - height: $o-statusbar-height + 1; + height: $o-statusbar-height; border-bottom: 1px solid transparent; flex: 0 1 auto; } } > .btn { - height: $o-statusbar-height + 1; + height: $o-statusbar-height; border: 1px solid transparent; } + .btn { + @extend .text-nowrap; + padding-right: $btn-padding-x; + padding-left: $btn-padding-x; + } } @@ -60,3 +65,11 @@ } } } + +@include media-breakpoint-down(sm) { + .o_chatter .o_chatter_topbar > .o_topbar_right_area { + .o_chatter_button_attachment { + margin-right: initial; + } + } +} diff --git a/addons/mail/static/src/scss/composer.scss b/addons/mail/static/src/scss/composer.scss index e7f7eb98d3b..75c62bbeae0 100644 --- a/addons/mail/static/src/scss/composer.scss +++ b/addons/mail/static/src/scss/composer.scss @@ -142,6 +142,9 @@ &:not(.o_chat_inline_composer) { flex-flow: row wrap; + .o_composer_container { + max-width: 100%; + } .o_composer_container, .o_composer_button_send { flex: 1 0 100%; } diff --git a/addons/mail/static/src/xml/thread.xml b/addons/mail/static/src/xml/thread.xml index 9f490bba0b5..2a5fd7ee30b 100644 --- a/addons/mail/static/src/xml/thread.xml +++ b/addons/mail/static/src/xml/thread.xml @@ -38,9 +38,9 @@ <t t-set="model" t-value="widget.modelName"/> <div class="o_viewer-header"> <span class="o_image_caption"> - <i class="fa fa-picture-o mr8" t-if="widget.activeAttachment.type == 'image'" role="img" aria-label="Image" title="Image"/> - <i class="fa fa-file-text mr8" t-if="widget.activeAttachment.type == 'application/pdf'" role="img" aria-label="PDF file" title="PDF file"/> - <i class="fa fa-video-camera mr8" t-if="widget.activeAttachment.type == 'video'" role="img" aria-label="Video" title="Video"/> + <i class="fa fa-picture-o mr8" t-if="widget.activeAttachment.fileType == 'image'" role="img" aria-label="Image" title="Image"/> + <i class="fa fa-file-text mr8" t-if="widget.activeAttachment.fileType == 'application/pdf'" role="img" aria-label="PDF file" title="PDF file"/> + <i class="fa fa-video-camera mr8" t-if="widget.activeAttachment.fileType == 'video'" role="img" aria-label="Video" title="Video"/> <t t-esc="widget.activeAttachment.name"/> <a role="button" href="#" class="o_download_btn ml8 small" data-toggle="tooltip" data-placement="right" title="Download"><i class="fa fa-fw fa-download" role="img" aria-label="Download"/></a> </span> @@ -48,19 +48,19 @@ </div> <div class="o_viewer_img_wrapper"> <div class="o_viewer_zoomer"> - <div t-if="widget.activeAttachment.type == 'image'" class="o_loading_img text-center"> + <div t-if="widget.activeAttachment.fileType == 'image'" class="o_loading_img text-center"> <i class="fa fa-circle-o-notch fa-spin text-gray-light fa-3x fa-fw" role="img" aria-label="Loading" title="Loading"/> </div> - <img t-if="widget.activeAttachment.type === 'image'" class="o_viewer_img" t-attf-src="/web/image/#{widget.activeAttachment.id}?unique=1&signature=#{widget.activeAttachment.checksum}&model=#{model}" alt="Viewer"/> - <iframe t-if="widget.activeAttachment.type == 'application/pdf'" class="mt32 o_viewer_pdf" t-attf-src="/web/static/lib/pdfjs/web/viewer.html?file=/web/content/#{widget.activeAttachment.id}?model%3D#{model}" /> - <iframe t-if="(widget.activeAttachment.type || '').indexOf('text') !== -1" class="mt32 o_viewer_text" t-attf-src="/web/content/#{widget.activeAttachment.id}?model%3D#{model}" /> - <iframe t-if="widget.activeAttachment.type == 'youtu'" class="mt32 o_viewer_text" allow="autoplay; encrypted-media" width="560" height="315" t-attf-src="https://www.youtube.com/embed/#{widget.activeAttachment.youtube}"/> - <video t-if="widget.activeAttachment.type == 'video'" class="o_viewer_video" controls="controls"> + <img t-if="widget.activeAttachment.fileType === 'image'" class="o_viewer_img" t-attf-src="/web/image/#{widget.activeAttachment.id}?unique=1&signature=#{widget.activeAttachment.checksum}&model=#{model}" alt="Viewer"/> + <iframe t-if="widget.activeAttachment.fileType == 'application/pdf'" class="mt32 o_viewer_pdf" t-attf-src="/web/static/lib/pdfjs/web/viewer.html?file=/web/content/#{widget.activeAttachment.id}?model%3D#{model}" /> + <iframe t-if="(widget.activeAttachment.fileType || '').indexOf('text') !== -1" class="mt32 o_viewer_text" t-attf-src="/web/content/#{widget.activeAttachment.id}?model%3D#{model}" /> + <iframe t-if="widget.activeAttachment.fileType == 'youtu'" class="mt32 o_viewer_text" allow="autoplay; encrypted-media" width="560" height="315" t-attf-src="https://www.youtube.com/embed/#{widget.activeAttachment.youtube}"/> + <video t-if="widget.activeAttachment.fileType == 'video'" class="o_viewer_video" controls="controls"> <source t-attf-src="/web/image/#{widget.activeAttachment.id}?model=#{model}" t-att-data-type="widget.activeAttachment.mimetype"/> </video> </div> </div> - <div t-if="widget.activeAttachment.type == 'image'" class="o_viewer_toolbar btn-toolbar" role="toolbar"> + <div t-if="widget.activeAttachment.fileType == 'image'" class="o_viewer_toolbar btn-toolbar" role="toolbar"> <div class="btn-group ml0" role="group"> <a role="button" href="#" class="o_viewer_toolbar_btn btn o_zoom_in" data-toggle="tooltip" title="Zoom In"><i class="fa fa-fw fa-plus" role="img" aria-label="Zoom In"/></a> <a role="button" href="#" class="o_viewer_toolbar_btn btn o_zoom_reset disabled" data-toggle="tooltip" title="Reset Zoom"><i class="fa fa-fw fa-search" role="img" aria-label="Reset Zoom"/></a> diff --git a/addons/mail/static/tests/document_viewer_tests.js b/addons/mail/static/tests/document_viewer_tests.js index 5d3dcf7069f..b5748ab72ee 100644 --- a/addons/mail/static/tests/document_viewer_tests.js +++ b/addons/mail/static/tests/document_viewer_tests.js @@ -207,5 +207,24 @@ QUnit.module('DocumentViewer', { assert.ok(viewer.isDestroyed(), 'viewer should be destroyed'); }); + + QUnit.test('fileType and integrity test', async function (assert) { + assert.expect(3); + + var viewer = await createViewer({ + attachmentID: 2, + attachments: this.attachments, + }); + + assert.strictEqual(this.attachments[1].type, 'url', + "the type should be url"); + assert.strictEqual(this.attachments[1].fileType, 'youtu', + "there should be a fileType 'youtu'"); + assert.strictEqual(this.attachments[1].youtube, 'FYqW0Gdwbzk', + "there should be a youtube token"); + + viewer.destroy(); + }); + }); }); diff --git a/addons/mass_mailing/models/mass_mailing_stats.py b/addons/mass_mailing/models/mass_mailing_stats.py index 662b6adbaaf..f0d8a763438 100644 --- a/addons/mass_mailing/models/mass_mailing_stats.py +++ b/addons/mass_mailing/models/mass_mailing_stats.py @@ -66,14 +66,14 @@ class MailMailStats(models.Model): stat.state = 'ignored' elif stat.exception: stat.state = 'exception' - elif stat.sent: - stat.state = 'sent' elif stat.opened or stat.clicked: stat.state = 'opened' elif stat.replied: stat.state = 'replied' elif stat.bounced: stat.state = 'bounced' + elif stat.sent: + stat.state = 'sent' else: stat.state = 'outgoing' diff --git a/addons/mass_mailing/wizard/mail_compose_message.py b/addons/mass_mailing/wizard/mail_compose_message.py index d0804977301..3f88e62552b 100644 --- a/addons/mass_mailing/wizard/mail_compose_message.py +++ b/addons/mass_mailing/wizard/mail_compose_message.py @@ -64,7 +64,8 @@ class MailComposeMessage(models.TransientModel): if mail_values.get('email_to'): mail_to = tools.email_normalize(mail_values['email_to']) else: - mail_to = tools.email_normalize(partners_email.get(res_id)) + partner_id = (mail_values.get('recipient_ids') or [(False, '')])[0][1] + mail_to = tools.email_normalize(partners_email.get(partner_id)) if (opt_out_list and mail_to in opt_out_list) or (seen_list and mail_to in seen_list) \ or (not mail_to or not email_re.findall(mail_to)): # prevent sending to blocked addresses that were included by mistake diff --git a/addons/mrp/models/mrp_abstract_workorder.py b/addons/mrp/models/mrp_abstract_workorder.py index 1a34ac13c68..c4e156ec4bb 100644 --- a/addons/mrp/models/mrp_abstract_workorder.py +++ b/addons/mrp/models/mrp_abstract_workorder.py @@ -24,9 +24,16 @@ class MrpAbstractWorkorder(models.AbstractModel): ('flexible', 'Flexible')], required=True, ) + use_create_components_lots = fields.Boolean(related="production_id.picking_type_id.use_create_components_lots") @api.onchange('qty_producing') def _onchange_qty_producing(self): + """ Modify the qty currently producing will modify the existing + workorder line in order to match the new quantity to consume for each + component and their reserved quantity. + """ + if self.qty_producing <= 0: + raise UserError(_('You have to produce at least one %s.') % self.product_uom_id.name) line_values = self._update_workorder_lines() for vals in line_values['to_create']: self.workorder_line_ids |= self.workorder_line_ids.new(vals) @@ -68,18 +75,24 @@ class MrpAbstractWorkorder(models.AbstractModel): # Remove or lower quantity on exisiting workorder lines if float_compare(qty_todo, 0.0, precision_rounding=rounding) < 0: qty_todo = abs(qty_todo) - for workorder_line in move_workorder_lines.sorted(key=lambda wl: wl.qty_reserved): + # Try to decrease or remove lines that are not reserved and + # partialy reserved first. A different decrease strategy could + # be define in _unreserve_order method. + for workorder_line in move_workorder_lines.sorted(key=lambda wl: wl._unreserve_order()): if float_compare(qty_todo, 0, precision_rounding=rounding) <= 0: break + # If the quantity to consume on the line is lower than the + # quantity to remove, the line could be remove. if float_compare(workorder_line.qty_to_consume, qty_todo, precision_rounding=rounding) <= 0: - # update qty_todo for next wo_line qty_todo = float_round(qty_todo - workorder_line.qty_to_consume, precision_rounding=rounding) if line_values['to_delete']: line_values['to_delete'] |= workorder_line else: line_values['to_delete'] = workorder_line + # decrease the quantity on the line else: new_val = workorder_line.qty_to_consume - qty_todo + # avoid to write a negative reserved quantity new_reserved = max(0, workorder_line.qty_reserved - qty_todo) line_values['to_update'][workorder_line] = { 'qty_to_consume': new_val, @@ -90,12 +103,17 @@ class MrpAbstractWorkorder(models.AbstractModel): else: # Search among wo lines which one could be updated qty_reserved_wl = defaultdict(float) + # Try to update the line with the greater reservation first in + # order to promote bigger batch. for workorder_line in move_workorder_lines.sorted(key=lambda wl: wl.qty_reserved, reverse=True): rounding = workorder_line.product_uom_id.rounding if float_compare(qty_todo, 0, precision_rounding=rounding) <= 0: break move_lines = workorder_line._get_move_lines() qty_reserved_wl[workorder_line.lot_id] += workorder_line.qty_reserved + # The reserved quantity according to exisiting move line + # already produced (with qty_done set) and other production + # lines with the same lot that are currently on production. qty_reserved_remaining = sum(move_lines.mapped('product_uom_qty')) - sum(move_lines.mapped('qty_done')) - qty_reserved_wl[workorder_line.lot_id] if float_compare(qty_reserved_remaining, 0, precision_rounding=rounding) > 0: qty_to_add = min(qty_reserved_remaining, qty_todo) @@ -107,6 +125,13 @@ class MrpAbstractWorkorder(models.AbstractModel): qty_todo -= qty_to_add qty_reserved_wl[workorder_line.lot_id] += qty_to_add + # If a line exists without reservation and without lot. It + # means that previous operations could not find any reserved + # quantity and created a line without lot prefilled. In this + # case, the system will not find an existing move line with + # available reservation anymore and will increase this line + # instead of creating a new line without lot and reserved + # quantities. if not workorder_line.qty_reserved and not workorder_line.lot_id and workorder_line.product_tracking != 'serial': line_values['to_update'][workorder_line] = { 'qty_done': workorder_line.qty_to_consume + qty_todo, @@ -123,9 +148,10 @@ class MrpAbstractWorkorder(models.AbstractModel): @api.model def _generate_lines_values(self, move, qty_to_consume): """ Create workorder line. First generate line based on the reservation, - in order to match the reservation. If the quantity to consume is greater - than the reservation quantity then create line with the correct quantity - to consume but without lot or serial number. + in order to prefill reserved quantity, lot and serial number. + If the quantity to consume is greater than the reservation quantity then + create line with the correct quantity to consume but without lot or + serial number. """ lines = [] is_tracked = move.product_id.tracking != 'none' @@ -220,8 +246,9 @@ class MrpAbstractWorkorder(models.AbstractModel): ) def _update_raw_moves(self): - """ Once the production is done, the lots written on workorder lines - are saved on stock move lines""" + """ Once the production is done. Modify the workorder lines into + stock move line with the registered lot and quantity done. + """ # Before writting produce quantities, we ensure they respect the bom strictness self._strict_consumption_check() vals_list = [] @@ -382,6 +409,11 @@ class MrpAbstractWorkorderLine(models.AbstractModel): return vals_list + def _unreserve_order(self): + """ Unreserve line with lower reserved quantity first """ + self.ensure_one() + return (self.qty_reserved,) + def _get_move_lines(self): return self.move_id.move_line_ids.filtered(lambda ml: ml.lot_id == self.lot_id and ml.product_id == self.product_id) diff --git a/addons/mrp/models/mrp_bom.py b/addons/mrp/models/mrp_bom.py index 284421cb283..4aa6117ac35 100644 --- a/addons/mrp/models/mrp_bom.py +++ b/addons/mrp/models/mrp_bom.py @@ -120,8 +120,7 @@ class MrpBom(models.Model): return super(MrpBom, self).unlink() @api.model - def _bom_find(self, product_tmpl=None, product=None, picking_type=None, company_id=False, bom_type=False): - """ Finds BoM for particular product, picking and company """ + def _bom_find_domain(self, product_tmpl=None, product=None, picking_type=None, company_id=False, bom_type=False): if product: if not product_tmpl: product_tmpl = product.product_tmpl_id @@ -130,7 +129,7 @@ class MrpBom(models.Model): domain = [('product_tmpl_id', '=', product_tmpl.id)] else: # neither product nor template, makes no sense to search - return False + raise UserError(_('You should provide either a product or a product template to search a BoM')) if picking_type: domain += ['|', ('picking_type_id', '=', picking_type.id), ('picking_type_id', '=', False)] if company_id or self.env.context.get('company_id'): @@ -138,6 +137,14 @@ class MrpBom(models.Model): if bom_type: domain += [('type', '=', bom_type)] # order to prioritize bom with product_id over the one without + return domain + + @api.model + def _bom_find(self, product_tmpl=None, product=None, picking_type=None, company_id=False, bom_type=False): + """ Finds BoM for particular product, picking and company """ + domain = self._bom_find_domain(product_tmpl=product_tmpl, product=product, picking_type=picking_type, company_id=company_id, bom_type=bom_type) + if domain is False: + return domain return self.search(domain, order='sequence, product_id', limit=1) def explode(self, product, quantity, picking_type=False): diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py index 46dcaf6e49a..4820c0ae4ac 100644 --- a/addons/mrp/models/mrp_production.py +++ b/addons/mrp/models/mrp_production.py @@ -408,7 +408,7 @@ class MrpProduction(models.Model): @api.onchange('location_src_id', 'move_raw_ids', 'routing_id') def _onchange_location(self): - source_location = self._get_raw_location() + source_location = self.location_src_id self.move_raw_ids.update({ 'warehouse_id': source_location.get_warehouse().id, 'location_id': source_location.id, @@ -501,7 +501,7 @@ class MrpProduction(models.Model): return self.env['stock.move'] if bom_line.product_id.type not in ['product', 'consu']: return self.env['stock.move'] - source_location = self._get_raw_location() + source_location = self.location_src_id data = { 'sequence': bom_line.sequence, 'name': self.name, @@ -528,16 +528,6 @@ class MrpProduction(models.Model): } return data - def _get_raw_location(self): - if self.routing_id: - routing = self.routing_id - else: - routing = self.bom_id.routing_id - if routing and routing.location_id: - return routing.location_id - else: - return self.location_src_id - @api.multi def _update_raw_move(self, bom_line, line_data): """ :returns update_move, old_quantity, new_quantity """ @@ -602,6 +592,7 @@ class MrpProduction(models.Model): def action_assign(self): for production in self: production.move_raw_ids._action_assign() + production.workorder_ids._refresh_wo_lines() return True @api.multi @@ -729,6 +720,7 @@ class MrpProduction(models.Model): }) if workorders: workorders[-1].next_work_order_id = workorder.id + workorders[-1]._start_nextworkorder() workorders += workorder # assign moves; last operation receive all unassigned moves (which case ?) @@ -739,7 +731,7 @@ class MrpProduction(models.Model): moves_raw.mapped('move_line_ids').write({'workorder_id': workorder.id}) (moves_finished + moves_raw).write({'workorder_id': workorder.id}) - workorder.generate_wo_lines() + workorder._generate_wo_lines() return workorders def _check_lots(self): diff --git a/addons/mrp/models/mrp_routing.py b/addons/mrp/models/mrp_routing.py index 976e86f3e50..3c907802074 100644 --- a/addons/mrp/models/mrp_routing.py +++ b/addons/mrp/models/mrp_routing.py @@ -20,11 +20,6 @@ class MrpRouting(models.Model): operation_ids = fields.One2many( 'mrp.routing.workcenter', 'routing_id', 'Operations', copy=True, oldname='workcenter_lines') - location_id = fields.Many2one( - 'stock.location', 'Production Location', - help="Keep empty if you produce at the location where you find the raw materials. " - "Set a location if you produce at a fixed location. This can be a partner location " - "if you subcontract the manufacturing operations.") company_id = fields.Many2one( 'res.company', 'Company', default=lambda self: self.env['res.company']._company_default_get('mrp.routing')) diff --git a/addons/mrp/models/mrp_workorder.py b/addons/mrp/models/mrp_workorder.py index 136537c13f7..3bdb4bd76f3 100644 --- a/addons/mrp/models/mrp_workorder.py +++ b/addons/mrp/models/mrp_workorder.py @@ -3,6 +3,7 @@ from datetime import datetime from dateutil.relativedelta import relativedelta +from collections import defaultdict from odoo import api, fields, models, _ from odoo.exceptions import UserError @@ -103,21 +104,60 @@ class MrpWorkorder(models.Model): capacity = fields.Float( 'Capacity', default=1.0, help="Number of pieces that can be produced in parallel.") - workorder_line_ids = fields.One2many('mrp.workorder.line', 'workorder_id', string='Workorder lines') - final_lot_domain = fields.One2many('stock.production.lot', compute="_compute_final_lot_domain") - - @api.depends('production_id') - def _compute_final_lot_domain(self): - for wo in self: - # check if self is not the first workorder in the list - if self.env['mrp.workorder'].search([('next_work_order_id', '=', wo.id)]): - wo.final_lot_domain = self.env['stock.production.lot'].search([ - ('use_next_on_work_order_id', '=', wo.id), - ]).ids + workorder_line_ids = fields.One2many( + 'mrp.workorder.line', + 'workorder_id', + string='Workorder lines', + domain=[('is_finished', '=', False)] + ) + finished_workorder_line_ids = fields.One2many( + 'mrp.workorder.line', + 'workorder_id', + string='Final Lot lines', + domain=[('is_finished', '=', True)] + ) + allowed_lots_domain = fields.One2many(comodel_name='stock.production.lot', compute="_compute_allowed_lots_domain") + + @api.onchange('final_lot_id') + def _onchange_final_lot_id(self): + """When the user changes the lot being currently produced, suggest + a quantity to produce consistent with the previous workorders. """ + previous_wo = self.env['mrp.workorder'].search([ + ('next_work_order_id', '=', self.id) + ]) + if previous_wo: + line = previous_wo.finished_workorder_line_ids.filtered(lambda line: line.lot_id == self.final_lot_id) + if line: + self.qty_producing = line.qty_done + + def _compute_allowed_lots_domain(self): + """ browse already created lot and respective quantities""" + productions = self.mapped('production_id') + for production in productions: + if production.product_id.tracking == 'none': + continue + + rounding = production.product_uom_id.rounding + finished_workorder_lines = production.workorder_ids.mapped('finished_workorder_line_ids') + qties_done_per_lot = defaultdict(list) + for finished_workorder_line in finished_workorder_lines: + qties_done_per_lot[finished_workorder_line.lot_id.id].append(finished_workorder_line.qty_done) + + qty_to_produce = production.product_qty + allowed_lot_ids = self.env['stock.production.lot'] + qty_produced = sum([max(qty_dones) for qty_dones in qties_done_per_lot.values()]) + if float_compare(qty_produced, qty_to_produce, precision_rounding=rounding) < 0: + # If we haven't produced enough, all lots are available + allowed_lot_ids = self.env['stock.production.lot'].search([('product_id', '=', production.product_id.id)]) else: - wo.final_lot_domain = self.env['stock.production.lot'].search([ - ('product_id', '=', wo.product_id.id), - ]).ids + # If we produced enough, only the already produced lots are available + allowed_lot_ids = self.env['stock.production.lot'].browse(qties_done_per_lot.keys()) + workorders = production.workorder_ids.filtered(lambda wo: wo.state not in ('done', 'cancel')) + for workorder in workorders: + if workorder.product_tracking == 'serial': + workorder.allowed_lots_domain = allowed_lot_ids - workorder.finished_workorder_line_ids.mapped('lot_id') + else: + workorder.allowed_lots_domain = allowed_lot_ids @api.multi def name_get(self): @@ -180,7 +220,7 @@ class MrpWorkorder(models.Model): raise UserError(_('The planned end date of the work order cannot be prior to the planned start date, please correct this to save the work order.')) return super(MrpWorkorder, self).write(values) - def generate_wo_lines(self): + def _generate_wo_lines(self): """ Generate workorder line """ self.ensure_one() raw_moves = self.move_raw_ids.filtered( @@ -195,9 +235,64 @@ class MrpWorkorder(models.Model): line_values = self._generate_lines_values(move, qty_to_consume) self.workorder_line_ids |= self.env['mrp.workorder.line'].create(line_values) - def _assign_default_final_lot_id(self): - self.final_lot_id = self.env['stock.production.lot'].search([('use_next_on_work_order_id', '=', self.id)], - order='create_date, id', limit=1) + def _apply_update_workorder_lines(self): + """ update existing line on the workorder. It could be trigger manually + after a modification of qty_producing. + """ + self.ensure_one() + line_values = self._update_workorder_lines() + self.workorder_line_ids |= self.workorder_line_ids.create(line_values['to_create']) + if line_values['to_delete']: + line_values['to_delete'].unlink() + for line, vals in line_values['to_update'].items(): + line.write(vals) + + def _refresh_wo_lines(self): + for workorder in self: + raw_moves = workorder.move_raw_ids.filtered( + lambda move: move.state not in ('done', 'cancel') + ) + wl_to_unlink = self.env['mrp.workorder.line'] + for move in raw_moves: + rounding = move.product_uom.rounding + qty_already_consumed = 0.0 + workorder_lines = workorder.workorder_line_ids.filtered(lambda w: w.move_id == move) + for wl in workorder_lines: + if not wl.qty_done: + wl_to_unlink |= wl + continue + + qty_already_consumed += wl.qty_done + qty_to_consume = move.product_uom._compute_quantity( + workorder.qty_producing * move.unit_factor, + move.product_id.uom_id, + round=False + ) + wl_to_unlink.unlink() + if float_compare(qty_to_consume, qty_already_consumed, precision_rounding=rounding) > 0: + line_values = workorder._generate_lines_values(move, qty_to_consume - qty_already_consumed) + workorder.workorder_line_ids |= self.env['mrp.workorder.line'].create(line_values) + + def _defaults_from_finished_workorder_line(self, reference_lot_lines): + for r_line in reference_lot_lines: + # see which lot we could suggest and its related qty_producing + if not r_line.lot_id: + continue + candidates = self.finished_workorder_line_ids.filtered(lambda line: line.lot_id == r_line.lot_id) + rounding = self.product_uom_id.rounding + if not candidates: + self.write({ + 'final_lot_id': r_line.lot_id.id, + 'qty_producing': r_line.qty_done, + }) + return True + elif float_compare(candidates.qty_done, r_line.qty_done, precision_rounding=rounding) < 0: + self.write({ + 'final_lot_id': r_line.lot_id.id, + 'qty_producing': r_line.qty_done - candidates.qty_done, + }) + return True + return False def _get_byproduct_move_line(self, by_product_move, quantity): return { @@ -216,47 +311,110 @@ class MrpWorkorder(models.Model): return True self.ensure_one() - if self.qty_producing <= 0: + if float_compare(self.qty_producing, 0, precision_rounding=self.product_uom_id.rounding) <= 0: raise UserError(_('Please set the quantity you are currently producing. It should be different from zero.')) - # One a piece is produced, you can launch the next work order - if self.next_work_order_id.state == 'pending': - self.next_work_order_id.state = 'ready' - # If last work order, then post lots used - # TODO: should be same as checking if for every workorder something has been done? if not self.next_work_order_id: self._update_finished_move() # Transfer quantities from temporary to final move line or make them final self._update_raw_moves() + # Transfert lot and quantity produced to a finished workorder line + if self.product_tracking != 'none' and self.final_lot_id: + self._create_or_update_finished_line() + # Update workorder quantity produced self.qty_produced += self.qty_producing - if self.final_lot_id: - self.final_lot_id.use_next_on_work_order_id = self.next_work_order_id - self.final_lot_id = False + # Suggest a finished lot on the next workorder + if self.next_work_order_id and self.production_id.product_id.tracking != 'none' and not self.next_work_order_id.final_lot_id: + self.next_work_order_id._defaults_from_finished_workorder_line(self.finished_workorder_line_ids) + # As we may have changed the quantity to produce on the next workorder, + # make sure to update its wokorder lines + self.next_work_order_id._apply_update_workorder_lines() + + # One a piece is produced, you can launch the next work order + self._start_nextworkorder() - # Set a qty producing + # Test if the production is done rounding = self.production_id.product_uom_id.rounding - if float_compare(self.qty_produced, self.production_id.product_qty, precision_rounding=rounding) >= 0: - self.qty_producing = 0 - elif self.production_id.product_id.tracking == 'serial': - self._assign_default_final_lot_id() - self.qty_producing = 1.0 - self.generate_wo_lines() + if float_compare(self.qty_produced, self.production_id.product_qty, precision_rounding=rounding) < 0: + previous_wo = self.env['mrp.workorder'] + if self.product_tracking != 'none': + previous_wo = self.env['mrp.workorder'].search([ + ('next_work_order_id', '=', self.id) + ]) + candidate_found_in_previous_wo = False + if previous_wo: + candidate_found_in_previous_wo = self._defaults_from_finished_workorder_line(previous_wo.finished_workorder_line_ids) + if not candidate_found_in_previous_wo: + # self is the first workorder + self.qty_producing = self.qty_remaining + self.final_lot_id = False + if self.product_tracking == 'serial': + self.qty_producing = 1 + + self._apply_update_workorder_lines() else: - self.qty_producing = float_round(self.production_id.product_qty - self.qty_produced, precision_rounding=rounding) - self.generate_wo_lines() - - if self.next_work_order_id and self.production_id.product_id.tracking != 'none': - self.next_work_order_id._assign_default_final_lot_id() - - if float_compare(self.qty_produced, self.production_id.product_qty, precision_rounding=rounding) >= 0: + self.qty_producing = 0 self.button_finish() return True + def _create_or_update_finished_line(self): + """ + 1. Check that the final lot and the quantity producing is valid regarding + other workorders of this production + 2. Save final lot and quantity producing to suggest on next workorder + """ + self.ensure_one() + final_lot_quantity = self.qty_production + rounding = self.product_uom_id.rounding + # Get the max quantity possible for current lot in other workorders + for workorder in (self.production_id.workorder_ids - self): + # We add the remaining quantity to the produced quantity for the + # current lot. For 5 finished products: if in the first wo it + # creates 4 lot A and 1 lot B and in the second it create 3 lot A + # and it remains 2 units to product, it could produce 5 lot A. + # In this case we select 4 since it would conflict with the first + # workorder otherwise. + line = workorder.finished_workorder_line_ids.filtered(lambda line: line.lot_id == self.final_lot_id) + if line and float_compare(line.qty_done + workorder.qty_remaining, final_lot_quantity, precision_rounding=rounding) <= 0: + final_lot_quantity = line.qty_done + workorder.qty_remaining + elif float_compare(workorder.qty_remaining, final_lot_quantity, precision_rounding=rounding) < 0: + final_lot_quantity = workorder.qty_remaining + + # final lot line for this lot on this workorder. + current_lot_lines = self.finished_workorder_line_ids.filtered(lambda line: line.lot_id == self.final_lot_id) + + # this lot has already been produced + if float_compare(final_lot_quantity, current_lot_lines.qty_done + self.qty_producing, precision_rounding=rounding) < 0: + raise UserError(_('You have produced %s %s of lot %s in the previous workorder. You are trying to produce %s in this one') % + (final_lot_quantity, self.product_id.uom_id.name, self.final_lot_id.name, current_lot_lines.qty_done + self.qty_producing)) + + # Update workorder line that regiter final lot created + if not current_lot_lines: + current_lot_lines = self.env['mrp.workorder.line'].create({ + 'workorder_id': self.id, + 'is_finished': True, + 'product_id': self.product_id.id, + 'lot_id': self.final_lot_id.id, + 'qty_done': self.qty_producing, + }) + else: + current_lot_lines.qty_done += self.qty_producing + + @api.multi + def _start_nextworkorder(self): + rounding = self.product_id.uom_id.rounding + if self.next_work_order_id.state == 'pending' and ( + (self.operation_id.batch == 'no' and + float_compare(self.qty_production, self.qty_produced, precision_rounding=rounding) <= 0) or + (self.operation_id.batch == 'yes' and + float_compare(self.operation_id.batch_size, self.qty_produced, precision_rounding=rounding) <= 0)): + self.next_work_order_id.state = 'ready' + @api.multi def button_start(self): self.ensure_one() @@ -391,6 +549,7 @@ class MrpWorkorderLine(models.Model): _description = "Workorder move line" workorder_id = fields.Many2one('mrp.workorder', 'Workorder') + is_finished = fields.Boolean('Finished Lot Line', default=False) def _get_final_lot(self): return self.workorder_id.final_lot_id diff --git a/addons/mrp/models/res_config_settings.py b/addons/mrp/models/res_config_settings.py index 5e47515b1f3..1dcbef8d67e 100644 --- a/addons/mrp/models/res_config_settings.py +++ b/addons/mrp/models/res_config_settings.py @@ -14,6 +14,7 @@ class ResConfigSettings(models.TransientModel): module_mrp_plm = fields.Boolean("Product Lifecycle Management (PLM)") module_mrp_workorder = fields.Boolean("Work Orders") module_quality_control = fields.Boolean("Quality") + module_mrp_subcontracting = fields.Boolean("Subcontracting") group_mrp_routings = fields.Boolean("MRP Work Orders", implied_group='mrp.group_mrp_routings') diff --git a/addons/mrp/models/stock_picking.py b/addons/mrp/models/stock_picking.py index bac9093f454..ce4cf49449b 100644 --- a/addons/mrp/models/stock_picking.py +++ b/addons/mrp/models/stock_picking.py @@ -14,6 +14,11 @@ class StockPickingType(models.Model): compute='_get_mo_count') count_mo_late = fields.Integer(string="Number of Manufacturing Orders Late", compute='_get_mo_count') + use_create_components_lots = fields.Boolean( + string="Create New Lots/Serial Numbers for Components", + help="Allow to create new lot/serial numbers for the components", + default=False, + ) def _get_mo_count(self): mrp_picking_types = self.filtered(lambda picking: picking.code == 'mrp_operation') diff --git a/addons/mrp/models/stock_production_lot.py b/addons/mrp/models/stock_production_lot.py index a8ca9f12e16..2df39cd7648 100644 --- a/addons/mrp/models/stock_production_lot.py +++ b/addons/mrp/models/stock_production_lot.py @@ -1,12 +1,17 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import api, exceptions, fields, models, _ +from odoo import models, _ +from odoo.exceptions import UserError class StockProductionLot(models.Model): _inherit = 'stock.production.lot' - use_next_on_work_order_id = fields.Many2one('mrp.workorder', - string="Next Work Order to Use", - help='Technical: used to figure out default serial number on work orders') + def _check_create(self): + active_mo_id = self.env.context.get('active_mo_id') + if active_mo_id: + active_mo = self.env['mrp.production'].browse(active_mo_id) + if not active_mo.picking_type_id.use_create_components_lots: + raise UserError(_('You are not allowed to create or edit a lot or serial number for the components with the operation type "Manufacturing". To change this, go on the operation type and tick the box "Create New Lots/Serial Numbers for Components".')) + return super(StockProductionLot, self)._check_create() diff --git a/addons/mrp/tests/test_order.py b/addons/mrp/tests/test_order.py index 1d5cec49cb3..f8aa7f29109 100644 --- a/addons/mrp/tests/test_order.py +++ b/addons/mrp/tests/test_order.py @@ -150,19 +150,13 @@ class TestMrpOrder(TestMrpCommon): self.assertEqual(production_2.reservation_state, 'assigned', 'Production order should be availability for assigned state') def test_empty_routing(self): - """ Checks an empty routing isn't usable on a manufacturing order""" - routing = self.env['mrp.routing'].create({ - 'name': 'Routing without operations', - 'location_id': self.warehouse_1.wh_input_stock_loc_id.id, - }) + """ Check what happens when you work with an empty routing""" + routing = self.env['mrp.routing'].create({'name': 'Routing without operations'}) self.bom_3.routing_id = routing.id production_form = Form(self.env['mrp.production']) production_form.product_id = self.product_6 - production_form.product_qty = 3 - production_form.product_uom_id = self.product_6.uom_id production = production_form.save() self.assertEqual(production.routing_id.id, False, 'The routing field should be empty on the mo') - self.assertEqual(production.move_raw_ids[0].location_id.id, self.warehouse_1.wh_input_stock_loc_id.id, 'Raw moves start location should have altered.') def test_multiple_post_inventory(self): """ Check the consumed quants of the produced quants when intermediate calls to `post_inventory` during a MO.""" @@ -414,6 +408,9 @@ class TestMrpOrder(TestMrpCommon): 'active_ids': [mo.id], })) produce_form.qty_producing = 1.0 + for i in range(len(produce_form.workorder_line_ids)): + with produce_form.workorder_line_ids.edit(i) as line: + line.qty_done += 1 product_produce = produce_form.save() product_produce.final_lot_id = final_product_lot.id # product 1 lot 1 shelf1 @@ -421,8 +418,6 @@ class TestMrpOrder(TestMrpCommon): # product 1 lot 2 self.assertEqual(len(product_produce.workorder_line_ids), 4, 'You should have 4 produce lines. lot 1 shelf_1, lot 1 shelf_2, lot2 and for product which have tracking None') - for produce_line in product_produce.workorder_line_ids: - produce_line.qty_done = produce_line.qty_to_consume + 1 product_produce.do_produce() move_1 = mo.move_raw_ids.filtered(lambda m: m.product_id == p1) @@ -697,6 +692,7 @@ class TestMrpOrder(TestMrpCommon): with self.assertRaises(UserError): # try adding another line for a bom product to increase the quantity + produce_form.qty_producing = 1 with produce_form.workorder_line_ids.new() as line: line.product_id = p1 line.qty_done = 1 @@ -715,6 +711,7 @@ class TestMrpOrder(TestMrpCommon): 'active_id': mo.id, 'active_ids': [mo.id], })) + produce_form.qty_producing = 1 with produce_form.workorder_line_ids.new() as line: line.product_id = self.product_4 line.qty_done = 1 diff --git a/addons/mrp/views/mrp_bom_views.xml b/addons/mrp/views/mrp_bom_views.xml index cbbd8e4c61a..dcc222c49b5 100644 --- a/addons/mrp/views/mrp_bom_views.xml +++ b/addons/mrp/views/mrp_bom_views.xml @@ -32,12 +32,12 @@ <field name="product_qty"/> <field name="product_uom_id" options="{'no_open':True,'no_create':True}" groups="uom.group_uom"/> </div> - <field name="routing_id" groups="mrp.group_mrp_routings"/> + <field name="routing_id" attrs="{'invisible': [('type','not in',('normal','phantom'))]}" groups="mrp.group_mrp_routings"/> </group> <group> <field name="code"/> <field name="type" widget="radio"/> - <p colspan="2" class="oe_grey oe_edit_only" attrs="{'invisible': [('type','=','normal')]}"> + <p colspan="2" class="oe_grey oe_edit_only" attrs="{'invisible': [('type','!=','phantom')]}"> <ul> A BoM of type kit is used to split the product into its components. <li> @@ -65,7 +65,7 @@ <field name="parent_product_tmpl_id" invisible="1" /> <field name="product_uom_id" options="{'no_open':True,'no_create':True}" groups="uom.group_uom"/> <field name="attribute_value_ids" widget="many2many_tags" options="{'no_create': True}" attrs="{'column_invisible': [('parent.product_id', '!=', False)]}" groups="product.group_product_variant"/> - <field name="operation_id" groups="mrp.group_mrp_routings" domain="[('routing_id', '=', parent.routing_id)]" options="{'no_quick_create':True,'no_create_edit':True}"/> + <field name="operation_id" groups="mrp.group_mrp_routings" attrs="{'column_invisible': [('parent.type','not in', ('normal', 'phantom'))]}" domain="[('routing_id', '=', parent.routing_id)]" options="{'no_quick_create':True,'no_create_edit':True}"/> </tree> </field> </page> @@ -73,11 +73,11 @@ <group> <group> <field name="sequence"/> - <field name="consumption" attrs="{'invisible': [('type','=','phantom')]}"/> + <field name="consumption" attrs="{'invisible': [('type','!=','normal')]}"/> </group> <group> - <field name="ready_to_produce" attrs="{'invisible': [('type','=','phantom')]}" string="Manufacturing Readiness"/> - <field name="picking_type_id" attrs="{'invisible': [('type','=','phantom')]}" string="Operation"/> + <field name="ready_to_produce" attrs="{'invisible': [('type','!=','normal')]}" string="Manufacturing Readiness"/> + <field name="picking_type_id" attrs="{'invisible': [('type','!=','normal')]}" string="Operation"/> </group> </group> </page> diff --git a/addons/mrp/views/mrp_production_views.xml b/addons/mrp/views/mrp_production_views.xml index be2ead0ec44..ed05eca749c 100644 --- a/addons/mrp/views/mrp_production_views.xml +++ b/addons/mrp/views/mrp_production_views.xml @@ -315,6 +315,7 @@ <field name="view_id" eval="False"/> <field name="search_view_id" ref="view_mrp_production_filter"/> <field name="context">{'search_default_todo': True}</field> + <field name="domain">[('picking_type_id.active', '=', True)]</field> <field name="help" type="html"> <p class="o_view_nocontent_smiling_face"> Create a new manufacturing order diff --git a/addons/mrp/views/mrp_routing_views.xml b/addons/mrp/views/mrp_routing_views.xml index 8a4b55848d8..c78c2161ddc 100644 --- a/addons/mrp/views/mrp_routing_views.xml +++ b/addons/mrp/views/mrp_routing_views.xml @@ -85,7 +85,6 @@ <field name="name"/> </group> <group> - <field name="location_id" groups="stock.group_adv_location" domain="[('usage', '!=', 'view')]"/> <field name="company_id" options="{'no_create': True}" groups="base.group_multi_company"/> </group> </group> @@ -109,7 +108,6 @@ <field name="code"/> <field name="name"/> <field name="active" invisible="1"/> - <field name="location_id" groups="stock.group_adv_location"/> <field name="company_id" groups="base.group_multi_company"/> </tree> </field> @@ -145,11 +143,7 @@ <search string="Routing"> <field name="name" string="Routing" filter_domain="['|',('name','ilike',self),('code','ilike',self)]"/> <filter string="Archived" name="inactive" domain="[('active','=',False)]"/> - <field name="location_id"/> <field name="company_id" groups="base.group_multi_company"/> - <group expand="0" string="Group By..."> - <filter string="Production Location" name="production_location" domain="[]" context="{'group_by':'location_id'}"/> - </group> </search> </field> </record> diff --git a/addons/mrp/views/mrp_workorder_views.xml b/addons/mrp/views/mrp_workorder_views.xml index 0888cb670f7..7ac0c81ffd8 100644 --- a/addons/mrp/views/mrp_workorder_views.xml +++ b/addons/mrp/views/mrp_workorder_views.xml @@ -105,7 +105,7 @@ <field name="is_user_working" invisible="1"/> <field name="working_state" invisible="1"/> <field name="production_state" invisible="1"/> - <field name="final_lot_domain" invisible="1"/> + <field name="allowed_lots_domain" invisible="1"/> <header> <button name="button_finish" type="object" string="Finish Order" attrs="{'invisible': ['|', ('state', '!=', 'progress'), ('is_produced', '=', False)]}" class="btn-info"/> <button name="button_start" type="object" string="Start Working" attrs="{'invisible': ['|', ('working_state', '=', 'blocked'), ('state', '!=', 'pending')]}"/> @@ -149,7 +149,7 @@ <group> <group> <field name="qty_producing" string="Quantity in Production" attrs="{'readonly': ['|', ('product_tracking', '=', 'serial'), ('state', 'in', ('done', 'cancel'))]}"/> - <field name="final_lot_id" context="{'default_product_id': product_id}" domain="[('id', 'in', final_lot_domain)]" attrs="{'invisible': [('product_tracking', '=', 'none')]}" groups="stock.group_production_lot"/> + <field name="final_lot_id" context="{'default_product_id': product_id}" domain="[('id', 'in', allowed_lots_domain)]" attrs="{'invisible': [('product_tracking', '=', 'none')]}" groups="stock.group_production_lot"/> <field name="product_tracking" invisible="1"/> </group> </group> @@ -158,7 +158,7 @@ <tree editable="bottom" create="0" delete="0"> <field name="product_id" string="Components"/> <field name="product_tracking" invisible="1"/> - <field name="lot_id" attrs="{'readonly': [('product_tracking', '=', 'none')]}" domain="[('product_id', '=', product_id)]" context="{'default_product_id': product_id}"/> + <field name="lot_id" attrs="{'readonly': [('product_tracking', '=', 'none')]}" domain="[('product_id', '=', product_id)]" context="{'default_product_id': product_id, 'active_mo_id': parent.production_id}"/> <field name="qty_to_consume" readonly="1"/> <field name="qty_reserved" readonly="1"/> <field name="qty_done" string="Done"/> @@ -166,6 +166,13 @@ <field name="move_id" invisible="1"/> </tree> </field> + <field name="finished_workorder_line_ids" attrs="{'invisible': [('finished_workorder_line_ids', '=', [])]}" readonly="1"> + <tree editable="bottom" create="0" delete="0"> + <field name="product_id"/> + <field name="lot_id"/> + <field name="qty_done" string="Done"/> + </tree> + </field> </page> <page string="Time Tracking" groups="mrp.group_mrp_manager"> <group> @@ -323,7 +330,7 @@ <field name="name">mrp.production.work.order.gantt.production</field> <field name="model">mrp.workorder</field> <field name="arch" type="xml"> - <gantt date_stop="date_planned_finished" date_start="date_planned_start" string="Operations" default_group_by="production_id"> + <gantt date_stop="date_planned_finished" date_start="date_planned_start" string="Operations" default_group_by="production_id" create="0"> </gantt> </field> </record> @@ -332,7 +339,7 @@ <field name="name">mrp.workorder.view.gantt</field> <field name="model">mrp.workorder</field> <field name="arch" type="xml"> - <gantt date_stop="date_planned_finished" date_start="date_planned_start" string="Operations" default_group_by="workcenter_id"> + <gantt date_stop="date_planned_finished" date_start="date_planned_start" string="Operations" default_group_by="workcenter_id" create="0"> </gantt> </field> </record> diff --git a/addons/mrp/views/res_config_settings_views.xml b/addons/mrp/views/res_config_settings_views.xml index ae48a0e1bea..7e09f27d481 100644 --- a/addons/mrp/views/res_config_settings_views.xml +++ b/addons/mrp/views/res_config_settings_views.xml @@ -44,6 +44,17 @@ </div> </div> </div> + <div class="col-lg-6 col-12 o_setting_box"> + <div class="o_setting_left_pane"> + <field name="module_mrp_subcontracting"/> + </div> + <div class="o_setting_right_pane"> + <label for="module_mrp_subcontracting"/> + <div class="text-muted"> + Subcontract the production of some products + </div> + </div> + </div> </div> <h2>Products</h2> <div class="row mt16 o_settings_container"> diff --git a/addons/mrp/views/stock_picking_views.xml b/addons/mrp/views/stock_picking_views.xml index 0bf2821ad19..97298d93e05 100644 --- a/addons/mrp/views/stock_picking_views.xml +++ b/addons/mrp/views/stock_picking_views.xml @@ -103,6 +103,11 @@ <field name="show_operations" position="attributes"> <attribute name="attrs">{"invisible": [("code", "=", "mrp_operation")]}</attribute> </field> + <xpath expr="//group[@name='stock_picking_type_lot']" position="after"> + <group attrs='{"invisible": [("code", "!=", "mrp_operation")]}' string="Traceability" groups="stock.group_production_lot"> + <field name="use_create_components_lots"/> + </group> + </xpath> </field> </record> </odoo> diff --git a/addons/mrp/wizard/mrp_product_produce_views.xml b/addons/mrp/wizard/mrp_product_produce_views.xml index 2c7954e1a6e..3864c6384a4 100644 --- a/addons/mrp/wizard/mrp_product_produce_views.xml +++ b/addons/mrp/wizard/mrp_product_produce_views.xml @@ -30,7 +30,7 @@ <tree editable="top" delete="0" decoration-danger="(qty_to_consume < qty_done)"> <field name="product_id" attrs="{'readonly': [('move_id', '!=', False)]}" required="1" domain="[('id', '!=', parent.product_id)]" force_save="1"/> <field name="product_tracking" invisible="1"/> - <field name="lot_id" attrs="{'readonly': [('product_tracking', '=', 'none')]}" context="{'default_product_id': product_id}" domain="[('product_id', '=', product_id)]" groups="stock.group_production_lot"/> + <field name="lot_id" attrs="{'readonly': [('product_tracking', '=', 'none')]}" context="{'default_product_id': product_id, 'active_mo_id': parent.production_id}" domain="[('product_id', '=', product_id)]" groups="stock.group_production_lot"/> <field name="qty_to_consume" readonly="1" force_save="1"/> <field name="qty_reserved" readonly="1" force_save="1"/> <field name="qty_done"/> diff --git a/addons/mrp_account/__init__.py b/addons/mrp_account/__init__.py new file mode 100644 index 00000000000..be9f4fab00a --- /dev/null +++ b/addons/mrp_account/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import models diff --git a/addons/mrp_account/__manifest__.py b/addons/mrp_account/__manifest__.py new file mode 100644 index 00000000000..de72516401a --- /dev/null +++ b/addons/mrp_account/__manifest__.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'Accounting - MRP', + 'version': '1.0', + 'category': 'Manufacturing', + 'summary': 'Analytic accounting in Manufacturing', + 'description': """ +Analytic Accounting in MRP +========================== + +* Cost structure report +""", + 'website': 'https://www.odoo.com/page/manufacturing', + 'depends': ['mrp', 'stock_account'], + 'installable': True, + 'auto_install': True, +} diff --git a/addons/mrp_account/models/__init__.py b/addons/mrp_account/models/__init__.py new file mode 100644 index 00000000000..872a43b7fff --- /dev/null +++ b/addons/mrp_account/models/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import mrp_workcenter +from . import mrp_production diff --git a/addons/mrp_account/models/mrp_production.py b/addons/mrp_account/models/mrp_production.py new file mode 100644 index 00000000000..2b42f62318e --- /dev/null +++ b/addons/mrp_account/models/mrp_production.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models +from odoo.tools import float_is_zero + + +class MrpProductionWorkcenterLineTime(models.Model): + _inherit = 'mrp.workcenter.productivity' + + cost_already_recorded = fields.Boolean('Cost Recorded', help="Technical field automatically checked when a ongoing production posts journal entries for its costs. This way, we can record one production's cost multiple times and only consider new entries in the work centers time lines.") + + +class MrpProduction(models.Model): + _inherit = 'mrp.production' + + extra_cost = fields.Float(copy=False, help='Extra cost per produced unit') + + def _cal_price(self, consumed_moves): + """Set a price unit on the finished move according to `consumed_moves`. + """ + super(MrpProduction, self)._cal_price(consumed_moves) + work_center_cost = 0 + finished_move = self.move_finished_ids.filtered(lambda x: x.product_id == self.product_id and x.state not in ('done', 'cancel') and x.quantity_done > 0) + if finished_move: + finished_move.ensure_one() + for work_order in self.workorder_ids: + time_lines = work_order.time_ids.filtered(lambda x: x.date_end and not x.cost_already_recorded) + duration = sum(time_lines.mapped('duration')) + time_lines.write({'cost_already_recorded': True}) + work_center_cost += (duration / 60.0) * work_order.workcenter_id.costs_hour + if finished_move.product_id.cost_method in ('fifo', 'average'): + qty_done = finished_move.product_uom._compute_quantity(finished_move.quantity_done, finished_move.product_id.uom_id) + extra_cost = self.extra_cost * qty_done + finished_move.price_unit = (sum([-m.value for m in consumed_moves]) + work_center_cost + extra_cost) / qty_done + finished_move.value = sum([-m.value for m in consumed_moves]) + work_center_cost + extra_cost + return True + + def _prepare_wc_analytic_line(self, wc_line): + wc = wc_line.workcenter_id + hours = wc_line.duration / 60.0 + value = hours * wc.costs_hour + account = wc.costs_hour_account_id.id + return { + 'name': wc_line.name + ' (H)', + 'amount': -value, + 'account_id': account, + 'ref': wc.code, + 'unit_amount': hours, + } + + def _costs_generate(self): + """ Calculates total costs at the end of the production. + """ + self.ensure_one() + AccountAnalyticLine = self.env['account.analytic.line'].sudo() + for wc_line in self.workorder_ids.filtered('workcenter_id.costs_hour_account_id'): + vals = self._prepare_wc_analytic_line(wc_line) + precision_rounding = wc_line.workcenter_id.costs_hour_account_id.currency_id.rounding + if not float_is_zero(vals.get('amount', 0.0), precision_rounding=precision_rounding): + # we use SUPERUSER_ID as we do not guarantee an mrp user + # has access to account analytic lines but still should be + # able to produce orders + AccountAnalyticLine.create(vals) + + @api.multi + def button_mark_done(self): + self.ensure_one() + res = super(MrpProduction, self).button_mark_done() + self._costs_generate() + return res diff --git a/addons/mrp_account/models/mrp_workcenter.py b/addons/mrp_account/models/mrp_workcenter.py new file mode 100644 index 00000000000..20c9fcd08d6 --- /dev/null +++ b/addons/mrp_account/models/mrp_workcenter.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class MrpWorkcenter(models.Model): + _inherit = 'mrp.workcenter' + + costs_hour_account_id = fields.Many2one('account.analytic.account', string='Analytic Account', + help="Fill this only if you want automatic analytic accounting entries on production orders.") diff --git a/addons/mrp_account/tests/__init__.py b/addons/mrp_account/tests/__init__.py new file mode 100644 index 00000000000..5b2f06a99ce --- /dev/null +++ b/addons/mrp_account/tests/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import test_mrp_account diff --git a/addons/mrp_account/tests/test_mrp_account.py b/addons/mrp_account/tests/test_mrp_account.py new file mode 100644 index 00000000000..64bba25019b --- /dev/null +++ b/addons/mrp_account/tests/test_mrp_account.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests import Form + +from odoo.tests import common + + +class TestMrpAccount(common.TransactionCase): + + def setUp(self): + super(TestMrpAccount, self).setUp() + self.categ_standard = self.env['product.category'].create({'name': 'STANDARD', + 'property_cost_method': 'standard',},) + self.categ_real = self.env['product.category'].create({'name': 'REAL', + 'property_cost_method': 'fifo',}) + self.categ_average = self.env['product.category'].create({'name': 'AVERAGE', + 'property_cost_method': 'average'}) + self.dining_table = self.env.ref("mrp.product_product_computer_desk") + self.dining_table.categ_id = self.categ_real.id + self.product_table_sheet = self.env.ref('mrp.product_product_computer_desk_head') + self.product_table_sheet.categ_id = self.categ_real.id + self.product_table_leg = self.env.ref('mrp.product_product_computer_desk_leg') + self.product_table_leg.categ_id = self.categ_average.id + self.product_bolt = self.env.ref('mrp.product_product_computer_desk_bolt') + self.product_bolt.categ_id = self.categ_standard.id + self.source_location_id = self.ref('stock.stock_location_14') + self.product_screw = self.env.ref('mrp.product_product_computer_desk_screw') + self.product_screw.categ_id = self.categ_standard.id + self.env['stock.move'].search([('product_id', 'in', [self.product_bolt.id, self.product_screw.id])])._do_unreserve() + (self.product_bolt + self.product_screw).write({'type': 'product'}) + self.product_desk = self.env.ref('mrp.product_product_computer_desk') + self.product_desk.tracking = 'none' + + def test_00_production_order_with_accounting(self): + self.product_table_sheet.standard_price = 20.0 + self.product_table_leg.standard_price = 15.0 + self.product_bolt.standard_price = 10.0 + self.product_table_leg.tracking = 'none' + self.product_table_sheet.tracking = 'none' + inventory = self.env['stock.inventory'].create({ + 'name': 'Inventory Product Table', + 'filter': 'partial', + 'line_ids': [(0, 0, { + 'product_id': self.product_table_sheet.id, # tracking serial + 'product_uom_id': self.product_table_sheet.uom_id.id, + 'product_qty': 20, + 'location_id': self.source_location_id + }), (0, 0, { + 'product_id': self.product_table_leg.id, # tracking lot + 'product_uom_id': self.product_table_leg.uom_id.id, + 'product_qty': 20, + 'location_id': self.source_location_id + }), (0, 0, { + 'product_id': self.product_bolt.id, + 'product_uom_id': self.product_bolt.uom_id.id, + 'product_qty': 20, + 'location_id': self.source_location_id + }), (0, 0, { + 'product_id': self.product_screw.id, + 'product_uom_id': self.product_screw.uom_id.id, + 'product_qty': 200000, + 'location_id': self.source_location_id + }), + ] + }) + inventory.action_validate + bom = self.env.ref('mrp.mrp_bom_desk').copy() + bom.routing_id = False # TODO: extend the test later with the necessary operations + production_table_form = Form(self.env['mrp.production']) + production_table_form.product_id = self.dining_table + production_table_form.bom_id = bom + production_table_form.product_qty = 5.0 + production_table = production_table_form.save() + + production_table.extra_cost = 20 + production_table.action_confirm() + + produce_form = Form(self.env['mrp.product.produce'].with_context({ + 'active_id': production_table.id, + 'active_ids': [production_table.id], + })) + produce_form.qty_producing = 1.0 + produce_wizard = produce_form.save() + produce_wizard.do_produce() + production_table.post_inventory() + move_value = production_table.move_finished_ids.filtered(lambda x: x.state == "done").value + + # 1 table head at 20 + 4 table leg at 15 + 4 bolt at 10 + 10 screw at 10 + 1*20 (extra cost) + self.assertEqual(move_value, 141, 'Thing should have the correct price') + +# produce_wizard = self.env['mrp.product.produce'].with_context({ +# 'active_id': production_table.id, +# 'active_ids': [production_table.id], +# }).create({ +# 'product_qty': 2.0, +# }) +# produce_wizard.do_produce() +# production_table.post_inventory() +# move_value = production_table.move_finished_ids.filtered(lambda x: x.state == "done" and x.product_qty == 2.0).value +# # 2 * Real price of the head (quant: 20) + standard price screw (product: 20*10) + standard price bolt (product: 8*10) +# self.assertEqual(move_value, 280, 'Thing should have the correct price') diff --git a/addons/mrp_byproduct/views/mrp_bom_views.xml b/addons/mrp_byproduct/views/mrp_bom_views.xml index 9132209d75c..e9448109521 100644 --- a/addons/mrp_byproduct/views/mrp_bom_views.xml +++ b/addons/mrp_byproduct/views/mrp_bom_views.xml @@ -7,7 +7,7 @@ <field name="inherit_id" ref="mrp.mrp_bom_form_view"/> <field name="arch" type="xml"> <notebook position="inside"> - <page string="Byproducts" attrs="{'invisible': [('type','=','phantom')]}"> + <page string="Byproducts" attrs="{'invisible': [('type','!=','normal')]}"> <field name="sub_products"> <tree string="Byproducts" editable="top"> <field name="product_id"/> diff --git a/addons/mrp_subcontracting/__init__.py b/addons/mrp_subcontracting/__init__.py new file mode 100644 index 00000000000..be29e5c5628 --- /dev/null +++ b/addons/mrp_subcontracting/__init__.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- + +from . import models +from odoo import api, SUPERUSER_ID + +def _create_subcontracting_rules(cr, registry): + """ This hook is used to add the default subcontracting rules on every warehouse/company. It is + necessary if the mrp_subcontracting module is installed after some warehouses + were already created. + """ + env = api.Environment(cr, SUPERUSER_ID, {}) + warehouse_ids = env['stock.warehouse'].search(['|', ('subcontracting_mto_pull_id', '=', False), ('subcontracting_pull_id', '=', False)]) + for warehouse_id in warehouse_ids: + warehouse_id.write({'subcontracting_to_resupply': warehouse_id.subcontracting_to_resupply}) + company_ids = env['res.company'].search([('subcontracting_location_id', '!=', False)]) + for company_id in company_ids: + wh = env['stock.warehouse'].search([('company_id', '=', company_id.id), ('active', '=', True)], limit=1) + production_location = wh._get_production_location() + rsoo_rule = env['stock.rule'].search([ + ('location_src_id', '=', company_id.subcontracting_location_id.id), + ('location_id', '=', production_location.id), + ('picking_type_id', '=', wh.manu_type_id.id) + ]) + if not rsoo_rule: + company_id._create_resupply_subcontractor_rules() diff --git a/addons/mrp_subcontracting/__manifest__.py b/addons/mrp_subcontracting/__manifest__.py new file mode 100644 index 00000000000..d6fbd927bdb --- /dev/null +++ b/addons/mrp_subcontracting/__manifest__.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': "mrp_subcontracting", + 'version': '0.1', + 'summary': "Subcontract Productions", + 'description': "", + 'website': 'https://www.odoo.com/page/manufacturing', + 'category': 'Manufacturing/Manufacturing', + 'depends': ['mrp', 'contacts'], + 'data': [ + 'data/mrp_subcontracting_data.xml', + 'views/stock_warehouse_views.xml', + 'views/stock_picking_views.xml', + 'views/mrp_bom_views.xml', + ], + 'post_init_hook': '_create_subcontracting_rules', +} diff --git a/addons/mrp_subcontracting/data/mrp_subcontracting_data.xml b/addons/mrp_subcontracting/data/mrp_subcontracting_data.xml new file mode 100644 index 00000000000..3966da5b9f2 --- /dev/null +++ b/addons/mrp_subcontracting/data/mrp_subcontracting_data.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <data noupdate="1"> + <record id="route_resupply_subcontractor_mto" model='stock.location.route'> + <field name="name">Resupply Subcontractor on Order</field> + <field name="company_id"></field> + <field name="sequence">5</field> + </record> + <function model="res.company" name="create_missing_subcontracting_location" /> + </data> +</odoo> + diff --git a/addons/mrp_subcontracting/models/__init__.py b/addons/mrp_subcontracting/models/__init__.py new file mode 100644 index 00000000000..98238bd9a1b --- /dev/null +++ b/addons/mrp_subcontracting/models/__init__.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- + +from . import mrp_bom +from . import res_company +from . import res_partner +from . import stock_move +from . import stock_picking +from . import stock_warehouse + diff --git a/addons/mrp_subcontracting/models/mrp_bom.py b/addons/mrp_subcontracting/models/mrp_bom.py new file mode 100644 index 00000000000..8f0d87d0b73 --- /dev/null +++ b/addons/mrp_subcontracting/models/mrp_bom.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class MrpBom(models.Model): + _inherit = 'mrp.bom' + + type = fields.Selection(selection_add=[('subcontract', 'Subcontracting')]) + subcontractor_ids = fields.One2many('res.partner', 'bom_id', domain=[('type', '=', 'subcontractor')], string='Subcontractors') + + def _bom_subcontract_find(self, product_tmpl=None, product=None, picking_type=None, company_id=False, bom_type='subcontract', subcontractor=False): + domain = self._bom_find_domain(product_tmpl=product_tmpl, product=product, picking_type=picking_type, company_id=company_id, bom_type=bom_type) + if subcontractor: + domain += ['|', ('subcontractor_ids', 'in', subcontractor.id), ('subcontractor_ids', '=', False)] + else: + domain += [('subcontractor_ids', '=', False)] + return self.search(domain, order='subcontractor_ids, sequence, product_id', limit=1) + diff --git a/addons/mrp_subcontracting/models/res_company.py b/addons/mrp_subcontracting/models/res_company.py new file mode 100644 index 00000000000..93687fa4d27 --- /dev/null +++ b/addons/mrp_subcontracting/models/res_company.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ + + +class ResCompany(models.Model): + _inherit = 'res.company' + + subcontracting_location_id = fields.Many2one('stock.location') + + def _create_per_company_locations(self): + super(ResCompany, self)._create_per_company_locations() + self._create_subcontracting_location() + + def _create_subcontracting_location(self): + for company in self: + parent_location = self.env.ref('stock.stock_location_locations_partner', raise_if_not_found=False) + subcontracting_location = self.env['stock.location'].create({ + 'name': _('%s: Subcontracting Location') % company.name, + 'usage': 'internal', + 'location_id': parent_location.id, + 'company_id': company.id, + }) + company.write({'subcontracting_location_id': subcontracting_location.id}) + + @api.model + def create_missing_subcontracting_location(self): + company_without_subcontracting_loc = self.env['res.company'].search( + [('subcontracting_location_id', '=', False)]) + for company in company_without_subcontracting_loc: + company._create_subcontracting_location() + + def _create_resupply_subcontractor_rules(self): + self.ensure_one() + wh = self.env['stock.warehouse'].search([('company_id', '=', self.id), ('active', '=', True)], limit=1) + subcontracting_location = self.subcontracting_location_id + production_location = wh._get_production_location() + resupply_route = wh._find_global_route('mrp_subcontracting.route_resupply_subcontractor_mto', _('Resupply Subcontractor on Order')) + resupply_route.rule_ids = [(0, 0, { + 'name': '%s → %s' % (subcontracting_location.name, production_location.name), + 'action': 'pull', + 'picking_type_id': wh.subcontracting_type_id, + 'location_src_id': subcontracting_location, + 'location_id': production_location, + })], + diff --git a/addons/mrp_subcontracting/models/res_partner.py b/addons/mrp_subcontracting/models/res_partner.py new file mode 100644 index 00000000000..d4d565120e6 --- /dev/null +++ b/addons/mrp_subcontracting/models/res_partner.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ + + +class ResPartner(models.Model): + _inherit = 'res.partner' + + type = fields.Selection(selection_add=[('subcontractor', 'Subcontractor')]) + bom_id = fields.Many2one('mrp.bom', domain=[('type', '=', 'subcontract')]) + + def _get_name(self): + name = super(ResPartner, self)._get_name() + if self.type == 'subcontractor': + subcontractor = _('Subcontractor') + if name.endswith(', '): + name = "%s%s" % (name, subcontractor) + else: + name = '%s, %s' % (name, subcontractor) + return name + + @api.onchange('type') + def _onchange_partner_type(self): + if self.type == 'subcontractor': + company = self.company_id or self.parent_id.company_id + subcontracting_location = company.subcontracting_location_id + self.update({ + 'property_stock_customer': subcontracting_location, + 'property_stock_supplier': subcontracting_location + }) + else: + self.update({ + 'property_stock_customer': self.env.ref('stock.stock_location_customers'), + 'property_stock_supplier': self.env.ref('stock.stock_location_suppliers') + }) + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if vals.get('type') == 'subcontractor': + if not vals.get('property_stock_supplier') and not vals.get('property_stock_customer'): + company = vals.get('company_id') and self.env['res.company'].browse(vals['company_id']) or \ + self.browse(vals.get('parent_id')).company_id + subcontracting_location = company.subcontracting_location_id + vals['property_stock_supplier'] = subcontracting_location + vals['property_stock_customer'] = subcontracting_location + return super(ResPartner, self).create(vals_list) + + def write(self, vals): + if vals.get('type') == 'subcontractor': + subcontracting_location = self.env.user.company_id.subcontracting_location_id + if not vals.get('property_stock_supplier') and not vals.get('property_stock_customer'): + vals['property_stock_supplier'] = subcontracting_location.id + vals['property_stock_customer'] = subcontracting_location.id + return super(ResPartner, self).write(vals) diff --git a/addons/mrp_subcontracting/models/stock_move.py b/addons/mrp_subcontracting/models/stock_move.py new file mode 100644 index 00000000000..daf847891bf --- /dev/null +++ b/addons/mrp_subcontracting/models/stock_move.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from collections import defaultdict + +from odoo import models, _ +from odoo.exceptions import UserError + + +class StockMove(models.Model): + _inherit = 'stock.move' + + def _get_subcontract_bom(self): + self.ensure_one() + bom = self.env['mrp.bom']._bom_subcontract_find( + product=self.product_id, + picking_type=self.picking_type_id, + company_id=self.company_id.id, + bom_type='subcontract', + subcontractor=self.picking_id.partner_id, + ) + return bom + + def _action_confirm(self, merge=True, merge_into=False): + res = super(StockMove, self)._action_confirm(merge=merge, merge_into=merge_into) + subcontract_details_per_picking = defaultdict(list) + for move in res: + if not move.picking_id: + continue + if not move.picking_id._is_subcontract(): + continue + bom = move._get_subcontract_bom() + if not bom: + error_message = _('Please define a BoM of type subcontracting for the product "%s". If you don\'t want to subcontract the product "%s", do not select a partner of type subcontractor.') + error_message += '\n\n' + error_message += _('If there is well a BoM of type subcontracting defined, check if you have set the correct subcontractors on it.') + raise UserError(error_message % (move.product_id.name, move.product_id.name)) + subcontract_details_per_picking[move.picking_id].append((move, bom)) + for picking, subcontract_details in subcontract_details_per_picking.items(): + picking._subcontracted_produce(subcontract_details) + return res diff --git a/addons/mrp_subcontracting/models/stock_picking.py b/addons/mrp_subcontracting/models/stock_picking.py new file mode 100644 index 00000000000..b3c93fb8b7e --- /dev/null +++ b/addons/mrp_subcontracting/models/stock_picking.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +class StockPicking(models.Model): + _inherit = 'stock.picking' + + display_action_record_components = fields.Boolean(compute='_compute_display_action_record_components') + display_view_subcontracted_move_lines = fields.Boolean(compute='_compute_display_view_subcontracted_move_lines') + + @api.depends('state') + def _compute_display_action_record_components(self): + for picking in self: + # Hide if not encoding state + if picking.state in ('draft', 'cancel', 'done'): + continue + if not picking._is_subcontract(): + continue + # Hide if no move is tracked + subcontracted_productions = picking._get_subcontracted_productions() + subcontracted_moves = subcontracted_productions.mapped('move_raw_ids') + subcontracted_moves |= subcontracted_productions.mapped('move_finished_ids') + if all(subcontracted_move.has_tracking == 'none' for subcontracted_move in subcontracted_moves): + continue + # Hide if the production is to close + if not subcontracted_productions.filtered(lambda mo: mo.state not in ('to_close', 'done')): + continue + picking.display_action_record_components = True + + @api.depends('state') + def _compute_display_view_subcontracted_move_lines(self): + for picking in self: + # Hide if not encoding state + if picking.state in ('draft', 'cancel'): + continue + if not picking._is_subcontract(): + continue + # Hide until state done if no move is tracked, if tracked until something was produced + subcontracted_productions = picking._get_subcontracted_productions() + subcontracted_moves = subcontracted_productions.mapped('move_raw_ids') + subcontracted_moves |= subcontracted_productions.mapped('move_finished_ids') + if all(subcontracted_move.has_tracking == 'none' for subcontracted_move in subcontracted_moves): + if picking.state != 'done': + continue + # Hide if nothing was produced + if all(subcontracted_move.quantity_done == 0 for subcontracted_move in subcontracted_moves): + continue + picking.display_view_subcontracted_move_lines = True + + # ------------------------------------------------------------------------- + # Action methods + # ------------------------------------------------------------------------- + def action_done(self): + for picking in self: + subcontracted_productions = picking._get_subcontracted_productions() + for subcontracted_production in subcontracted_productions: + subcontracted_production.button_mark_done() + return super(StockPicking, self).action_done() + + def action_view_subcontracted_move_lines(self): + """ Returns a list view with the move lines of the subcontracted products. To find them, we + look on the origin moves of the move lines of the picking if there is a manufacturing order. + """ + self.ensure_one() + subcontracted_productions = self._get_subcontracted_productions() + subcontracted_move_lines = self.env['stock.move.line'] + for subcontracted_production in subcontracted_productions: + subcontracted_move_lines |= subcontracted_production.move_raw_ids.mapped('move_line_ids') + subcontracted_move_lines |= subcontracted_production.move_finished_ids.mapped('move_line_ids') + action = self.env.ref('stock.stock_move_line_action').read()[0] + action['context'] = {} + action['domain'] = [('id', 'in', subcontracted_move_lines.ids)] + return action + + def action_record_components(self): + self.ensure_one() + subcontracted_productions = self._get_subcontracted_productions() + to_register = subcontracted_productions.filtered(lambda mo: mo.state not in ('to_close', 'done')) + if to_register: + mo = to_register[0] + action = self.env.ref('mrp.act_mrp_product_produce').read()[0] + action['context'] = dict(self.env.context, active_id=mo.id) + return action + + # ------------------------------------------------------------------------- + # Subcontract helpers + # ------------------------------------------------------------------------- + def _is_subcontract(self): + self.ensure_one() + if self.partner_id.type == 'subcontractor' and \ + self.picking_type_id.code == 'incoming': + return True + return False + + def _get_subcontracted_productions(self): + self.ensure_one() + return self.move_lines.mapped('move_orig_ids.production_id') + + def _prepare_subcontract_mo_vals(self, subcontract_move, bom): + subcontract_move.ensure_one() + product = subcontract_move.product_id + warehouse = subcontract_move.warehouse_id or self.picking_type_id.warehouse_id + vals = { + 'product_id': product.id, + 'product_uom_id': subcontract_move.product_uom.id, + 'bom_id': bom.id, + 'location_src_id': subcontract_move.picking_id.partner_id.property_stock_supplier.id, + 'location_dest_id': subcontract_move.location_id.id, + 'product_qty': subcontract_move.product_uom_qty, + 'picking_type_id': warehouse.subcontracting_type_id.id + } + return vals + + def _subcontracted_produce(self, subcontract_details): + self.ensure_one() + for move, bom in subcontract_details: + mo = self.env['mrp.production'].create(self._prepare_subcontract_mo_vals(move, bom)) + self.env['stock.move'].create(mo._get_moves_raw_values()) + mo.action_confirm() + + # Link the finished to the receipt move. + finished_move = mo.move_finished_ids.filtered(lambda m: m.product_id == move.product_id) + finished_move.write({'move_dest_ids': [(4, move.id, False)]}) + mo.action_assign() + + # Only skip the produce wizard if no moves is tracked + moves = mo.mapped('move_raw_ids') + mo.mapped('move_finished_ids') + if any(move.has_tracking != 'none' for move in moves): + continue + for m in moves: + m.quantity_done = m.product_uom_qty diff --git a/addons/mrp_subcontracting/models/stock_warehouse.py b/addons/mrp_subcontracting/models/stock_warehouse.py new file mode 100644 index 00000000000..709b86043be --- /dev/null +++ b/addons/mrp_subcontracting/models/stock_warehouse.py @@ -0,0 +1,141 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ + + +class StockWarehouse(models.Model): + _inherit = 'stock.warehouse' + + subcontracting_to_resupply = fields.Boolean( + 'Resupply Subcontractors', default=True, + help="Resupply subcontractors with components") + + subcontracting_mto_pull_id = fields.Many2one( + 'stock.rule', 'Subcontracting MTO Rule') + subcontracting_pull_id = fields.Many2one( + 'stock.rule', 'Subcontracting MTS Rule' + ) + + subcontracting_route_id = fields.Many2one('stock.location.route', 'Resupply Subcontractor', ondelete='restrict') + + subcontracting_type_id = fields.Many2one( + 'stock.picking.type', 'Subcontracting Operation Type', + domain=[('code', '=', 'mrp_operation')]) + + def get_rules_dict(self): + result = super(StockWarehouse, self).get_rules_dict() + subcontract_location_id = self._get_subcontracting_location() + for warehouse in self: + result[warehouse.id].update({ + 'subcontract': [ + self.Routing(warehouse.lot_stock_id, subcontract_location_id, warehouse.out_type_id, 'pull'), + ] + }) + return result + + def _get_routes_values(self): + routes = super(StockWarehouse, self)._get_routes_values() + routes.update({ + 'subcontracting_route_id': { + 'routing_key': 'subcontract', + 'depends': ['subcontracting_to_resupply'], + 'route_create_values': { + 'product_categ_selectable': False, + 'warehouse_selectable': True, + 'product_selectable': False, + 'company_id': self.company_id.id, + 'sequence': 10, + 'name': self._format_routename(name=_('Resupply Subcontractor')) + }, + 'route_update_values': { + 'active': self.subcontracting_to_resupply, + }, + 'rules_values': { + 'active': self.subcontracting_to_resupply, + } + } + }) + return routes + + def _get_global_route_rules_values(self): + rules = super(StockWarehouse, self)._get_global_route_rules_values() + subcontract_location_id = self._get_subcontracting_location() + production_location_id = self._get_production_location() + rules.update({ + 'subcontracting_mto_pull_id': { + 'depends': ['subcontracting_to_resupply'], + 'create_values': { + 'procure_method': 'make_to_order', + 'company_id': self.company_id.id, + 'action': 'pull', + 'auto': 'manual', + 'propagate': True, + 'route_id': self._find_global_route('stock.route_warehouse0_mto', _('Make To Order')).id, + 'name': self._format_rulename(self.lot_stock_id, subcontract_location_id, 'MTO'), + 'location_id': subcontract_location_id.id, + 'location_src_id': self.lot_stock_id.id, + 'picking_type_id': self.out_type_id.id + }, + 'update_values': { + 'active': self.subcontracting_to_resupply + } + }, + 'subcontracting_pull_id': { + 'depends': ['subcontracting_to_resupply'], + 'create_values': { + 'procure_method': 'make_to_order', + 'company_id': self.company_id.id, + 'action': 'pull', + 'auto': 'manual', + 'propagate': True, + 'route_id': self._find_global_route('mrp_subcontracting.route_resupply_subcontractor_mto', + _('Resupply Subcontractor on Order')).id, + 'name': self._format_rulename(self.lot_stock_id, subcontract_location_id, False), + 'location_id': production_location_id.id, + 'location_src_id': subcontract_location_id.id, + 'picking_type_id': self.out_type_id.id + }, + 'update_values': { + 'active': self.subcontracting_to_resupply + } + }, + }) + return rules + + def _get_picking_type_create_values(self, max_sequence): + data, next_sequence = super(StockWarehouse, self)._get_picking_type_create_values(max_sequence) + data.update({ + 'subcontracting_type_id': { + 'name': _('Subcontracting'), + 'code': 'mrp_operation', + 'use_create_lots': True, + 'use_existing_lots': True, + 'sequence': next_sequence + 2 + }, + }) + return data, max_sequence + 4 + + def _get_sequence_values(self): + values = super(StockWarehouse, self)._get_sequence_values() + values.update({ + 'subcontracting_type_id': {'name': self.name + ' ' + _('Sequence subcontracting'), 'prefix': self.code + '/SBC/', 'padding': 5}, + }) + return values + + def _get_picking_type_update_values(self): + data = super(StockWarehouse, self)._get_picking_type_update_values() + subcontract_location_id = self._get_subcontracting_location() + production_location_id = self._get_production_location() + data.update({ + 'subcontracting_type_id': { + 'active': False, + 'default_location_src_id': subcontract_location_id.id, + 'default_location_dest_id': production_location_id.id, + }, + }) + return data + + def _get_subcontracting_location(self): + return self.company_id.subcontracting_location_id + diff --git a/addons/mrp_subcontracting/tests/__init__.py b/addons/mrp_subcontracting/tests/__init__.py new file mode 100644 index 00000000000..c8d5649e39d --- /dev/null +++ b/addons/mrp_subcontracting/tests/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- + +from . import test_subcontracting + diff --git a/addons/mrp_subcontracting/tests/test_subcontracting.py b/addons/mrp_subcontracting/tests/test_subcontracting.py new file mode 100644 index 00000000000..4ed43c5946d --- /dev/null +++ b/addons/mrp_subcontracting/tests/test_subcontracting.py @@ -0,0 +1,525 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests import Form +from odoo.tests.common import TransactionCase + +from odoo.tests import tagged + +@tagged('post_install', '-at_install') +class TestSubcontractingBasic(TransactionCase): + def test_partner_1(self): + """ Checks the `display_name` of subcontracted partners. """ + main_partner = self.env['res.partner'].create({'name': 'main_partner'}) + + # When a name is set to the subcontracted partner. + subcontractor_partner1 = self.env['res.partner'].create({ + 'name': 'subcontractor_partner', + 'type': 'subcontractor', + 'parent_id': main_partner.id, + }) + self.assertEqual(subcontractor_partner1.display_name, "main_partner, subcontractor_partner, Subcontractor") + + # When a name is not set to the subcontracted partner. + subcontractor_partner1 = self.env['res.partner'].create({ + 'name': '', + 'type': 'subcontractor', + 'parent_id': main_partner.id, + }) + self.assertEqual(subcontractor_partner1.display_name, "main_partner, Subcontractor") + + def test_partner_2(self): + """ Open an existing partner and add a contact of type subcontractor (simple form view). + Checks the customer and supplier locations are set to the subcontracting one onces saved. + """ + that_company = self.env['res.partner'].create({ + 'is_company': True, + 'name': "That Company", + }) + partner_form = Form(that_company) + with partner_form.child_ids.new() as partner: + partner.name = 'That Guy' + partner.type = 'subcontractor' + partner_form.save() + subcontractor = that_company.child_ids + self.assertEquals(subcontractor.property_stock_supplier, self.env.user.company_id.subcontracting_location_id) + self.assertEquals(subcontractor.property_stock_customer, self.env.user.company_id.subcontracting_location_id) + + def test_partner_3(self): + """ Create a new partner, select a parent company, and choose type subcontractor (debug + active). Checks the onchange correctly set the customer and supplier locations to the + subcontracting ones. Change them to another one, save, checks they are rightly applied. + """ + # FIXME sle: this field should not be writeable without the debug mode but it is. ask xmo + group_no_one = self.env.ref('base.group_no_one') + self.env.user.write({'groups_id': [(3, group_no_one.id)]}) + partner_form = Form(self.env['res.partner']) + partner_form.name = 'subcontractor partner' + self.assertNotEqual(partner_form.property_stock_supplier, self.env.user.company_id.subcontracting_location_id) + self.assertNotEqual(partner_form.property_stock_customer, self.env.user.company_id.subcontracting_location_id) + partner_form.type = 'subcontractor' + self.assertEqual(partner_form.property_stock_supplier, self.env.user.company_id.subcontracting_location_id) + self.assertEqual(partner_form.property_stock_customer, self.env.user.company_id.subcontracting_location_id) + subcontracting_location2 = self.env.user.company_id.subcontracting_location_id.copy() + partner_form.property_stock_supplier = subcontracting_location2 + partner_form.property_stock_customer = subcontracting_location2 + partner = partner_form.save() + self.assertEqual(partner.property_stock_supplier, subcontracting_location2) + self.assertEqual(partner.property_stock_customer, subcontracting_location2) + + def test_partner_4(self): + """ Create a new partner, select a parent company, and choose type subcontractor (debug + not active). Checks the customer and supplier locations are set to the subcontracting one + onces saved. + """ + that_company = self.env['res.partner'].create({ + 'is_company': True, + 'name': "That Company", + }) + partner_form = Form(self.env['res.partner']) + partner_form.name = 'That Guy' + partner_form.parent_id = that_company + partner_form.type = 'subcontractor' + subcontractor = partner_form.save() + self.assertEquals(subcontractor.property_stock_supplier, self.env.user.company_id.subcontracting_location_id) + self.assertEquals(subcontractor.property_stock_customer, self.env.user.company_id.subcontracting_location_id) + + def test_partner_5(self): + """ Create a new partner with a child partner of type contact, + then write the type 'subcontractor' on it + """ + that_company = self.env['res.partner'].create({ + 'is_company': True, + 'name': "That Company", + }) + partner_form = Form(that_company) + with partner_form.child_ids.new() as partner: + partner.name = 'That Guy' + partner.type = 'contact' + partner_form.save() + + # We write a new type on the created child partner + subcontractor = that_company.child_ids + self.assertEquals(len(that_company.child_ids), 1) + subcontractor.type = 'subcontractor' + + self.assertEquals(subcontractor.property_stock_supplier, self.env.user.company_id.subcontracting_location_id) + self.assertEquals(subcontractor.property_stock_customer, self.env.user.company_id.subcontracting_location_id) + + def test_subcontracting_location_1(self): + """ Checks the creation and presence of the subcontracting location. """ + self.assertTrue(self.env.user.company_id.subcontracting_location_id) + self.assertTrue(self.env.user.company_id.subcontracting_location_id.active) + company2 = self.env['res.company'].create({'name': 'Test Company'}) + self.assertTrue(company2.subcontracting_location_id) + + +class TestSubcontractingFlows(TransactionCase): + def setUp(self): + super(TestSubcontractingFlows, self).setUp() + # 1: Create a subcontracting partner + main_partner = self.env['res.partner'].create({'name': 'main_partner'}) + self.subcontractor_partner1 = self.env['res.partner'].create({ + 'name': 'subcontractor_partner', + 'type': 'subcontractor', + 'parent_id': main_partner.id, + }) + + # 2. Create a BOM of subcontracting type + self.comp1 = self.env['product.product'].create({ + 'name': 'Component1', + 'type': 'product', + 'categ_id': self.env.ref('product.product_category_all').id, + }) + self.comp2 = self.env['product.product'].create({ + 'name': 'Component2', + 'type': 'product', + 'categ_id': self.env.ref('product.product_category_all').id, + }) + self.finished = self.env['product.product'].create({ + 'name': 'finished', + 'type': 'product', + 'categ_id': self.env.ref('product.product_category_all').id, + }) + bom_form = Form(self.env['mrp.bom']) + bom_form.type = 'subcontract' + bom_form.product_tmpl_id = self.finished.product_tmpl_id + with bom_form.bom_line_ids.new() as bom_line: + bom_line.product_id = self.comp1 + bom_line.product_qty = 1 + with bom_form.bom_line_ids.new() as bom_line: + bom_line.product_id = self.comp2 + bom_line.product_qty = 1 + self.bom = bom_form.save() + + # Create a BoM for self.comp2 + self.comp2comp = self.env['product.product'].create({ + 'name': 'component for Component2', + 'type': 'product', + 'categ_id': self.env.ref('product.product_category_all').id, + }) + bom_form = Form(self.env['mrp.bom']) + bom_form.product_tmpl_id = self.comp2.product_tmpl_id + with bom_form.bom_line_ids.new() as bom_line: + bom_line.product_id = self.comp2comp + bom_line.product_qty = 1 + self.comp2_bom = bom_form.save() + + def test_flow_1(self): + """ Don't tick any route on the components and trigger the creation of the subcontracting + manufacturing order through a receipt picking. Create a reordering rule in the + subcontracting locations for a component and run the scheduler to resupply. Checks if the + resupplying actually works + """ + # Create a receipt picking from the subcontractor + picking_form = Form(self.env['stock.picking']) + picking_form.picking_type_id = self.env.ref('stock.picking_type_in') + picking_form.partner_id = self.subcontractor_partner1 + with picking_form.move_ids_without_package.new() as move: + move.product_id = self.finished + move.product_uom_qty = 1 + picking_receipt = picking_form.save() + picking_receipt.action_confirm() + + # Nothing should be tracked + self.assertFalse(picking_receipt.display_action_record_components) + self.assertFalse(picking_receipt.display_view_subcontracted_move_lines) + + # Check the created manufacturing order + mo = self.env['mrp.production'].search([('bom_id', '=', self.bom.id)]) + self.assertEqual(len(mo), 1) + self.assertEqual(len(mo.picking_ids), 0) + wh = picking_receipt.picking_type_id.warehouse_id + self.assertEquals(mo.picking_type_id, wh.subcontracting_type_id) + self.assertFalse(mo.picking_type_id.active) + + # Create a RR + pg1 = self.env['procurement.group'].create({}) + self.env['stock.warehouse.orderpoint'].create({ + 'name': 'xxx', + 'product_id': self.comp1.id, + 'product_min_qty': 0, + 'product_max_qty': 0, + 'location_id': self.env.user.company_id.subcontracting_location_id.id, + 'group_id': pg1.id, + }) + + # Run the scheduler and check the created picking + self.env['procurement.group'].run_scheduler() + picking = self.env['stock.picking'].search([('group_id', '=', pg1.id)]) + self.assertEqual(len(picking), 1) + self.assertEquals(picking.picking_type_id, wh.out_type_id) + picking_receipt.move_lines.quantity_done = 1 + picking_receipt.button_validate() + self.assertEquals(mo.state, 'done') + + # Now that the picking is done, the details stat button should be visible + self.assertTrue(picking_receipt.display_view_subcontracted_move_lines) + + # Available quantities should be negative at the subcontracting location for each components + avail_qty_comp1 = self.env['stock.quant']._get_available_quantity(self.comp1, self.subcontractor_partner1.property_stock_supplier, allow_negative=True) + avail_qty_comp2 = self.env['stock.quant']._get_available_quantity(self.comp2, self.subcontractor_partner1.property_stock_supplier, allow_negative=True) + avail_qty_finished = self.env['stock.quant']._get_available_quantity(self.finished, wh.lot_stock_id) + self.assertEquals(avail_qty_comp1, -1) + self.assertEquals(avail_qty_comp2, -1) + self.assertEquals(avail_qty_finished, 1) + + def test_flow_2(self): + """ Tick "Resupply Subcontractor on Order" on the components and trigger the creation of + the subcontracting manufacturing order through a receipt picking. Checks if the resupplying + actually works. + """ + # Tick "resupply subconractor on order" + resupply_sub_on_order_route = self.env['stock.location.route'].search([('name', '=', 'Resupply Subcontractor on Order')]) + (self.comp1 + self.comp2).write({'route_ids': [(4, resupply_sub_on_order_route.id, None)]}) + + # Create a receipt picking from the subcontractor + picking_form = Form(self.env['stock.picking']) + picking_form.picking_type_id = self.env.ref('stock.picking_type_in') + picking_form.partner_id = self.subcontractor_partner1 + with picking_form.move_ids_without_package.new() as move: + move.product_id = self.finished + move.product_uom_qty = 1 + picking_receipt = picking_form.save() + picking_receipt.action_confirm() + + # Nothing should be tracked + self.assertFalse(picking_receipt.display_action_record_components) + self.assertFalse(picking_receipt.display_view_subcontracted_move_lines) + + # Pickings should directly be created + mo = self.env['mrp.production'].search([('bom_id', '=', self.bom.id)]) + self.assertEqual(len(mo.picking_ids), 1) + self.assertEquals(mo.state, 'to_close') + self.assertEqual(len(mo.picking_ids.move_lines), 2) + + picking = mo.picking_ids + wh = picking.picking_type_id.warehouse_id + + # The picking should be a delivery order + self.assertEquals(picking.picking_type_id, wh.out_type_id) + + self.assertEquals(mo.picking_type_id, wh.subcontracting_type_id) + self.assertFalse(mo.picking_type_id.active) + + # No manufacturing order for `self.comp2` + comp2mo = self.env['mrp.production'].search([('bom_id', '=', self.comp2_bom.id)]) + self.assertEqual(len(comp2mo), 0) + + picking_receipt.move_lines.quantity_done = 1 + picking_receipt.button_validate() + self.assertEquals(mo.state, 'done') + + # Now that the picking is done, the details stat button should be visible + self.assertTrue(picking_receipt.display_view_subcontracted_move_lines) + + # Available quantities should be negative at the subcontracting location for each components + avail_qty_comp1 = self.env['stock.quant']._get_available_quantity(self.comp1, self.subcontractor_partner1.property_stock_supplier, allow_negative=True) + avail_qty_comp2 = self.env['stock.quant']._get_available_quantity(self.comp2, self.subcontractor_partner1.property_stock_supplier, allow_negative=True) + avail_qty_finished = self.env['stock.quant']._get_available_quantity(self.finished, wh.lot_stock_id) + self.assertEquals(avail_qty_comp1, -1) + self.assertEquals(avail_qty_comp2, -1) + self.assertEquals(avail_qty_finished, 1) + + def test_flow_3(self): + """ Tick "Resupply Subcontractor on Order" and "MTO" on the components and trigger the + creation of the subcontracting manufacturing order through a receipt picking. Checks if the + resupplying actually works. One of the component has also "manufacture" set and a BOM + linked. Checks that an MO is created for this one. + """ + # Tick "resupply subconractor on order" + resupply_sub_on_order_route = self.env['stock.location.route'].search([('name', '=', 'Resupply Subcontractor on Order')]) + (self.comp1 + self.comp2).write({'route_ids': [(4, resupply_sub_on_order_route.id, None)]}) + + # Tick "manufacture" and MTO on self.comp2 + mto_route = self.env['stock.location.route'].search([('name', '=', 'Replenish on Order (MTO)')]) + manufacture_route = self.env['stock.location.route'].search([('name', '=', 'Manufacture')]) + self.comp2.write({'route_ids': [(4, manufacture_route.id, None)]}) + self.comp2.write({'route_ids': [(4, mto_route.id, None)]}) + + # Create a receipt picking from the subcontractor + picking_form = Form(self.env['stock.picking']) + picking_form.picking_type_id = self.env.ref('stock.picking_type_in') + picking_form.partner_id = self.subcontractor_partner1 + with picking_form.move_ids_without_package.new() as move: + move.product_id = self.finished + move.product_uom_qty = 1 + picking_receipt = picking_form.save() + picking_receipt.action_confirm() + + # Nothing should be tracked + self.assertFalse(picking_receipt.display_action_record_components) + self.assertFalse(picking_receipt.display_view_subcontracted_move_lines) + + # Pickings should directly be created + mo = self.env['mrp.production'].search([('bom_id', '=', self.bom.id)]) + self.assertEqual(len(mo.picking_ids), 1) + self.assertEquals(mo.state, 'to_close') + self.assertEqual(len(mo.picking_ids.move_lines), 2) + + # The picking should be a delivery order + wh = picking_receipt.picking_type_id.warehouse_id + self.assertEquals(mo.picking_ids.picking_type_id, wh.out_type_id) + + self.assertEquals(mo.picking_type_id, wh.subcontracting_type_id) + self.assertFalse(mo.picking_type_id.active) + + # As well as a manufacturing order for `self.comp2` + comp2mo = self.env['mrp.production'].search([('bom_id', '=', self.comp2_bom.id)]) + self.assertEqual(len(comp2mo), 1) + picking_receipt.move_lines.quantity_done = 1 + picking_receipt.button_validate() + self.assertEquals(mo.state, 'done') + + # Now that the picking is done, the details stat button should be visible + self.assertTrue(picking_receipt.display_view_subcontracted_move_lines) + + # Available quantities should be negative at the subcontracting location for each components + avail_qty_comp1 = self.env['stock.quant']._get_available_quantity(self.comp1, self.subcontractor_partner1.property_stock_supplier, allow_negative=True) + avail_qty_comp2 = self.env['stock.quant']._get_available_quantity(self.comp2, self.subcontractor_partner1.property_stock_supplier, allow_negative=True) + avail_qty_finished = self.env['stock.quant']._get_available_quantity(self.finished, wh.lot_stock_id) + self.assertEquals(avail_qty_comp1, -1) + self.assertEquals(avail_qty_comp2, -1) + self.assertEquals(avail_qty_finished, 1) + + def test_flow_4(self): + """ Check that the correct BoM is chosen accordingly to the partner + """ + # We create a second partner of type subcontractor + main_partner_2 = self.env['res.partner'].create({'name': 'main_partner'}) + subcontractor_partner2 = self.env['res.partner'].create({ + 'name': 'subcontractor_partner', + 'type': 'subcontractor', + 'parent_id': main_partner_2.id, + }) + + # We create a different BoM for the same product + comp3 = self.env['product.product'].create({ + 'name': 'Component1', + 'type': 'product', + 'categ_id': self.env.ref('product.product_category_all').id, + }) + + bom_form = Form(self.env['mrp.bom']) + bom_form.type = 'subcontract' + bom_form.product_tmpl_id = self.finished.product_tmpl_id + with bom_form.bom_line_ids.new() as bom_line: + bom_line.product_id = self.comp1 + bom_line.product_qty = 1 + with bom_form.bom_line_ids.new() as bom_line: + bom_line.product_id = comp3 + bom_line.product_qty = 1 + bom2 = bom_form.save() + + # We assign the second BoM to the new partner + self.bom.write({'subcontractor_ids': [(4, self.subcontractor_partner1.id, None)]}) + bom2.write({'subcontractor_ids': [(4, subcontractor_partner2.id, None)]}) + + # Create a receipt picking from the subcontractor1 + picking_form = Form(self.env['stock.picking']) + picking_form.picking_type_id = self.env.ref('stock.picking_type_in') + picking_form.partner_id = self.subcontractor_partner1 + with picking_form.move_ids_without_package.new() as move: + move.product_id = self.finished + move.product_uom_qty = 1 + picking_receipt1 = picking_form.save() + picking_receipt1.action_confirm() + + # Create a receipt picking from the subcontractor2 + picking_form = Form(self.env['stock.picking']) + picking_form.picking_type_id = self.env.ref('stock.picking_type_in') + picking_form.partner_id = subcontractor_partner2 + with picking_form.move_ids_without_package.new() as move: + move.product_id = self.finished + move.product_uom_qty = 1 + picking_receipt2 = picking_form.save() + picking_receipt2.action_confirm() + + mo_pick1 = picking_receipt1.move_lines.mapped('move_orig_ids.production_id') + mo_pick2 = picking_receipt2.move_lines.mapped('move_orig_ids.production_id') + self.assertEquals(len(mo_pick1), 1) + self.assertEquals(len(mo_pick2), 1) + self.assertEquals(mo_pick1.bom_id, self.bom) + self.assertEquals(mo_pick2.bom_id, bom2) + + +class TestSubcontractingTracking(TransactionCase): + def setUp(self): + super(TestSubcontractingTracking, self).setUp() + # 1: Create a subcontracting partner + main_company_1 = self.env['res.partner'].create({'name': 'main_partner'}) + self.subcontractor_partner1 = self.env['res.partner'].create({ + 'name': 'Subcontractor 1', + 'type': 'subcontractor', + 'parent_id': main_company_1.id, + }) + + # 2. Create a BOM of subcontracting type + # 2.1. Comp1 has tracking by lot + self.comp1_sn = self.env['product.product'].create({ + 'name': 'Component1', + 'type': 'product', + 'categ_id': self.env.ref('product.product_category_all').id, + 'tracking': 'serial' + }) + self.comp2 = self.env['product.product'].create({ + 'name': 'Component2', + 'type': 'product', + 'categ_id': self.env.ref('product.product_category_all').id, + }) + + # 2.2. Finished prodcut has tracking by serial number + self.finished_lot = self.env['product.product'].create({ + 'name': 'finished', + 'type': 'product', + 'categ_id': self.env.ref('product.product_category_all').id, + 'tracking': 'lot' + }) + bom_form = Form(self.env['mrp.bom']) + bom_form.type = 'subcontract' + bom_form.product_tmpl_id = self.finished_lot.product_tmpl_id + with bom_form.bom_line_ids.new() as bom_line: + bom_line.product_id = self.comp1_sn + bom_line.product_qty = 1 + with bom_form.bom_line_ids.new() as bom_line: + bom_line.product_id = self.comp2 + bom_line.product_qty = 1 + self.bom_tracked = bom_form.save() + + def test_flow_tracked_1(self): + """ This test mimics test_flow_1 but with a BoM that has tracking included in it. + """ + # Create a receipt picking from the subcontractor + picking_form = Form(self.env['stock.picking']) + picking_form.picking_type_id = self.env.ref('stock.picking_type_in') + picking_form.partner_id = self.subcontractor_partner1 + with picking_form.move_ids_without_package.new() as move: + move.product_id = self.finished_lot + move.product_uom_qty = 1 + picking_receipt = picking_form.save() + picking_receipt.action_confirm() + + # We should be able to call the 'record_components' button + self.assertTrue(picking_receipt.display_action_record_components) + self.assertFalse(picking_receipt.display_view_subcontracted_move_lines) + + # Check the created manufacturing order + mo = self.env['mrp.production'].search([('bom_id', '=', self.bom_tracked.id)]) + self.assertEqual(len(mo), 1) + self.assertEquals(mo.state, 'confirmed') + self.assertEqual(len(mo.picking_ids), 0) + wh = picking_receipt.picking_type_id.warehouse_id + self.assertEquals(mo.picking_type_id, wh.subcontracting_type_id) + self.assertFalse(mo.picking_type_id.active) + + # Create a RR + pg1 = self.env['procurement.group'].create({}) + self.env['stock.warehouse.orderpoint'].create({ + 'name': 'xxx', + 'product_id': self.comp1_sn.id, + 'product_min_qty': 0, + 'product_max_qty': 0, + 'location_id': self.env.user.company_id.subcontracting_location_id.id, + 'group_id': pg1.id, + }) + + # Run the scheduler and check the created picking + self.env['procurement.group'].run_scheduler() + picking = self.env['stock.picking'].search([('group_id', '=', pg1.id)]) + self.assertEqual(len(picking), 1) + self.assertEquals(picking.picking_type_id, wh.out_type_id) + + lot_id = self.env['stock.production.lot'].create({ + 'name': 'lot1', + 'product_id': self.finished_lot.id + }) + serial_id = self.env['stock.production.lot'].create({ + 'name': 'lot1', + 'product_id': self.comp1_sn.id + }) + produce_form = Form(self.env['mrp.product.produce'].with_context({ + 'active_id': mo.id, + 'active_ids': [mo.id], + })) + produce_form.final_lot_id = lot_id + produce_form.workorder_line_ids._records[0]['lot_id'] = serial_id.id + wiz_produce = produce_form.save() + wiz_produce.do_produce() + + # We should not be able to call the 'record_components' button + self.assertFalse(picking_receipt.display_action_record_components) + # We should see the move lines stat button + self.assertTrue(picking_receipt.display_view_subcontracted_move_lines) + + picking_receipt.move_lines.quantity_done = 1 + picking_receipt.move_lines.move_line_ids.lot_name = 'lot00001test' + picking_receipt.button_validate() + self.assertEquals(mo.state, 'done') + + # Available quantities should be negative at the subcontracting location for each components + avail_qty_comp1 = self.env['stock.quant']._get_available_quantity(self.comp1_sn, self.subcontractor_partner1.property_stock_supplier, allow_negative=True) + avail_qty_comp2 = self.env['stock.quant']._get_available_quantity(self.comp2, self.subcontractor_partner1.property_stock_supplier, allow_negative=True) + avail_qty_finished = self.env['stock.quant']._get_available_quantity(self.finished_lot, wh.lot_stock_id) + self.assertEquals(avail_qty_comp1, -1) + self.assertEquals(avail_qty_comp2, -1) + self.assertEquals(avail_qty_finished, 1) diff --git a/addons/mrp_subcontracting/views/mrp_bom_views.xml b/addons/mrp_subcontracting/views/mrp_bom_views.xml new file mode 100644 index 00000000000..d384f1ab4ea --- /dev/null +++ b/addons/mrp_subcontracting/views/mrp_bom_views.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <record id="mrp_bom_form_view" model="ir.ui.view"> + <field name="name">mrp.bom.form.view</field> + <field name="model">mrp.bom</field> + <field name="inherit_id" ref="mrp.mrp_bom_form_view" /> + <field name="arch" type="xml"> + <xpath expr="//field[@name='type']" position="after"> + <field name="subcontractor_ids" widget="many2many_tags" context="{'default_type': 'subcontractor'}" attrs="{'invisible': [('type', '!=', 'subcontract')]}"/> + </xpath> + </field> + </record> +</odoo> + diff --git a/addons/mrp_subcontracting/views/stock_picking_views.xml b/addons/mrp_subcontracting/views/stock_picking_views.xml new file mode 100644 index 00000000000..23145eeabbf --- /dev/null +++ b/addons/mrp_subcontracting/views/stock_picking_views.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <record id="stock_picking_form_view" model="ir.ui.view"> + <field name="name">stock.picking.form.view</field> + <field name="model">stock.picking</field> + <field name="inherit_id" ref="stock.view_picking_form" /> + <field name="arch" type="xml"> + <xpath expr="//div[@name='button_box']" position="inside"> + <field name="display_view_subcontracted_move_lines" invisible="1" /> + <button string="Subcontracted Moves" type="object" + name="action_view_subcontracted_move_lines" + class="oe_stat_button" icon="fa-exchange" + attrs="{'invisible': [('display_view_subcontracted_move_lines', '=', False)]}" /> + </xpath> + + <xpath expr="//button[@name='button_validate'][hasclass('o_btn_validate')]" position="before"> + <field name="display_action_record_components" invisible="1" /> + <button name="action_record_components" class="oe_highlight" attrs="{'invisible': [('display_action_record_components', '=', False)]}" string="Record components" type="object"/> + </xpath> + </field> + </record> +</odoo> + diff --git a/addons/mrp_subcontracting/views/stock_warehouse_views.xml b/addons/mrp_subcontracting/views/stock_warehouse_views.xml new file mode 100644 index 00000000000..a2428f64246 --- /dev/null +++ b/addons/mrp_subcontracting/views/stock_warehouse_views.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <record id="view_warehouse_inherit_mrp_subcontracting" model="ir.ui.view"> + <field name="name">Stock Warehouse Inherit Subcontracting</field> + <field name="model">stock.warehouse</field> + <field name="inherit_id" ref="mrp.view_warehouse_inherit_mrp"/> + <field name="arch" type="xml"> + <xpath expr="//field[@name='manufacture_to_resupply']" position="before"> + <field name="subcontracting_to_resupply" /> + </xpath> + </field> + </record> +</odoo> diff --git a/addons/note/data/note_data.xml b/addons/note/data/note_data.xml index cffb8b081f8..b4d2518f0c9 100644 --- a/addons/note/data/note_data.xml +++ b/addons/note/data/note_data.xml @@ -4,25 +4,25 @@ <record model="note.stage" id="note_stage_00"> <field name="name">New</field> <field name="sequence">0</field> - <field name="user_id" eval="ref('base.user_root')"/> + <field name="user_id" eval="ref('base.user_admin')"/> </record> <record model="note.stage" id="note_stage_01"> <field name="name">Meeting Minutes</field> <field name="sequence">5</field> - <field name="user_id" eval="ref('base.user_root')"/> + <field name="user_id" eval="ref('base.user_admin')"/> </record> <record model="note.stage" id="note_stage_02"> <field name="name">Notes</field> <field name="sequence">10</field> - <field name="user_id" eval="ref('base.user_root')"/> + <field name="user_id" eval="ref('base.user_admin')"/> </record> <record model="note.stage" id="note_stage_03"> <field name="name">Todo</field> <field name="sequence">50</field> - <field name="user_id" eval="ref('base.user_root')"/> + <field name="user_id" eval="ref('base.user_admin')"/> </record> </data> </odoo> diff --git a/addons/payment/views/account_payment_views.xml b/addons/payment/views/account_payment_views.xml index dd9a5400be3..806abac1f5a 100644 --- a/addons/payment/views/account_payment_views.xml +++ b/addons/payment/views/account_payment_views.xml @@ -7,16 +7,8 @@ <field name="inherit_id" ref="account.view_account_payment_form"/> <field name="arch" type="xml"> <xpath expr='//group[2]' position="inside"> - <field name="payment_transaction_id"/> + <field name="payment_transaction_id" groups="base.group_no_one" attrs="{'invisible': [('payment_method_code', '!=', 'electronic')]}"/> </xpath> - </field> - </record> - - <record id="view_account_payment_invoice_form_inherit_payment" model="ir.ui.view"> - <field name="name">view.account.payment.invoice.form.inherit.payment</field> - <field name="model">account.payment</field> - <field name="inherit_id" ref="account.view_account_payment_invoice_form"/> - <field name="arch" type="xml"> <field name="payment_method_id" position="after"> <field name="payment_method_code" invisible="1"/> <field name="payment_token_id" options="{'no_create': True}" @@ -25,6 +17,5 @@ </field> </field> </record> - </data> </odoo> diff --git a/addons/payment_authorize/models/authorize_request.py b/addons/payment_authorize/models/authorize_request.py index bca2164dac1..79d0a7589c5 100644 --- a/addons/payment_authorize/models/authorize_request.py +++ b/addons/payment_authorize/models/authorize_request.py @@ -148,7 +148,8 @@ class AuthorizeAPI(): """ root = self._base_tree('createCustomerProfileRequest') profile = etree.SubElement(root, "profile") - etree.SubElement(profile, "merchantCustomerId").text = 'ODOO-%s-%s' % (partner.id, uuid4().hex[:8]) + # merchantCustomerId is ODOO-{partner.id}-{random hex string} truncated to maximum 20 characters + etree.SubElement(profile, "merchantCustomerId").text = ('ODOO-%s-%s' % (partner.id, uuid4().hex[:8]))[:20] etree.SubElement(profile, "email").text = partner.email or '' payment_profile = etree.SubElement(profile, "paymentProfiles") etree.SubElement(payment_profile, "customerType").text = 'business' if partner.is_company else 'individual' @@ -220,7 +221,8 @@ class AuthorizeAPI(): root = self._base_tree('createCustomerProfileFromTransactionRequest') etree.SubElement(root, "transId").text = transaction_id customer = etree.SubElement(root, "customer") - etree.SubElement(customer, "merchantCustomerId").text = 'ODOO-%s-%s' % (partner.id, uuid4().hex[:8]) + # merchantCustomerId is ODOO-{partner.id}-{random hex string} truncated to maximum 20 characters + etree.SubElement(customer, "merchantCustomerId").text = ('ODOO-%s-%s' % (partner.id, uuid4().hex[:8]))[:20] etree.SubElement(customer, "email").text = partner.email or '' response = self._authorize_request(root) res = dict() diff --git a/addons/point_of_sale/models/pos_session.py b/addons/point_of_sale/models/pos_session.py index d7a7f77c7fc..18dc7ca5227 100644 --- a/addons/point_of_sale/models/pos_session.py +++ b/addons/point_of_sale/models/pos_session.py @@ -283,6 +283,7 @@ class PosSession(models.Model): for session in self: company_id = session.config_id.company_id.id ctx = dict(self.env.context, force_company=company_id, company_id=company_id, default_partner_type='customer') + ctx_notrack = dict(ctx, mail_notrack=True) for st in session.statement_ids: if abs(st.difference) > st.journal_id.amount_authorized_diff: # The pos manager can close statements with maximums. @@ -290,7 +291,7 @@ class PosSession(models.Model): raise UserError(_("Your ending balance is too different from the theoretical cash closing (%.2f), the maximum allowed is: %.2f. You can contact your manager to force it.") % (st.difference, st.journal_id.amount_authorized_diff)) if (st.journal_id.type not in ['bank', 'cash']): raise UserError(_("The journal type for your payment method should be bank or cash.")) - st.with_context(ctx).sudo().button_confirm_bank() + st.with_context(ctx_notrack).sudo().button_confirm_bank() session.activity_unlink(['point_of_sale.mail_activity_old_session']) self.with_context(ctx)._confirm_orders() self.write({'state': 'closed'}) diff --git a/addons/point_of_sale/static/src/js/chrome.js b/addons/point_of_sale/static/src/js/chrome.js index ffc5d5cc860..f1a79a22064 100644 --- a/addons/point_of_sale/static/src/js/chrome.js +++ b/addons/point_of_sale/static/src/js/chrome.js @@ -720,9 +720,6 @@ var Chrome = PosBaseWidget.extend(AbstractAction.prototype, { if(err.message === 'XmlHttpRequestError '){ title = 'Network Failure (XmlHttpRequestError)'; body = 'The Point of Sale could not be loaded due to a network problem.\n Please check your internet connection.'; - }else if(err.message === 'TLSError'){ - title = 'Https connection to IoT Box failed'; - body = 'Make sure you are using IoT Box v18.12 or higher.\n\n Navigate to ' + err.url + ' to accept the certificate of your IoT Box.'; }else if(err.code === 200){ title = err.data.message; body = err.data.debug; diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js index 9ae632ae1d6..4d340b34b20 100644 --- a/addons/point_of_sale/static/src/js/models.js +++ b/addons/point_of_sale/static/src/js/models.js @@ -137,11 +137,16 @@ exports.PosModel = Backbone.Model.extend({ resolve(); }, function (statusText, url) { - if (statusText == 'error' && window.location.protocol == 'https:') { - var error = {message: 'TLSError', url: url}; - self.chrome.loading_error(error); - } else { - resolve(); + var show_loading_error = (self.gui.current_screen === null); + resolve(); + if (show_loading_error && statusText == 'error' && window.location.protocol == 'https:') { + self.gui.show_popup('alert', { + title: _t('HTTPS connection to IoT Box failed'), + body: _.str.sprintf( + _t('Make sure you are using IoT Box v18.12 or higher. Navigate to %s to accept the certificate of your IoT Box.'), + url + ), + }); } } ); diff --git a/addons/point_of_sale/tools/posbox/overwrite_after_init/etc/cups/cupsd.conf b/addons/point_of_sale/tools/posbox/overwrite_after_init/etc/cups/cupsd.conf index ee6cbbf93d4..cefc3cf06ab 100644 --- a/addons/point_of_sale/tools/posbox/overwrite_after_init/etc/cups/cupsd.conf +++ b/addons/point_of_sale/tools/posbox/overwrite_after_init/etc/cups/cupsd.conf @@ -20,6 +20,9 @@ Listen /run/cups/cups.sock Browsing On BrowseLocalProtocols dnssd +# Local printers are not shared by default +DefaultShared No + # Default authentication type, when authentication is required... DefaultAuthType Basic diff --git a/addons/point_of_sale/tools/posbox/overwrite_after_init/etc/nginx/sites-enabled/default b/addons/point_of_sale/tools/posbox/overwrite_after_init/etc/nginx/sites-enabled/default index cd95a43753a..fc802829d69 100644 --- a/addons/point_of_sale/tools/posbox/overwrite_after_init/etc/nginx/sites-enabled/default +++ b/addons/point_of_sale/tools/posbox/overwrite_after_init/etc/nginx/sites-enabled/default @@ -85,7 +85,7 @@ server { root /var/www/; index index.html; # -# location / { -# try_files $uri $uri/ =404; -# } + location /hw_drivers/ { + proxy_pass http://127.0.0.1:8069; + } } diff --git a/addons/point_of_sale/tools/posbox/overwrite_after_init/etc/xdg/openbox/autostart b/addons/point_of_sale/tools/posbox/overwrite_after_init/etc/xdg/openbox/autostart index 96ea352c761..d7b5349a74f 100755 --- a/addons/point_of_sale/tools/posbox/overwrite_after_init/etc/xdg/openbox/autostart +++ b/addons/point_of_sale/tools/posbox/overwrite_after_init/etc/xdg/openbox/autostart @@ -15,6 +15,7 @@ if [ -e /home/pi/odoo-remote-server.conf ] var=0 while [ ${var} -lt 30 ]; do var=$((var+1)) + sleep 1 status_code=$(wget ${URL_SERVER} --server-response 2>&1|grep 'HTTP/'|awk '{print $2}') if [ ${status_code} -eq 200 ] then diff --git a/addons/point_of_sale/tools/posbox/overwrite_after_init/home/pi/odoo/addons/point_of_sale/__manifest__.py b/addons/point_of_sale/tools/posbox/overwrite_after_init/home/pi/odoo/addons/point_of_sale/__manifest__.py new file mode 100644 index 00000000000..67dee8c60db --- /dev/null +++ b/addons/point_of_sale/tools/posbox/overwrite_after_init/home/pi/odoo/addons/point_of_sale/__manifest__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. diff --git a/addons/point_of_sale/tools/posbox/overwrite_after_init/usr/local/lib/python3.5/dist-packages/v4l2.py.iotpatch b/addons/point_of_sale/tools/posbox/overwrite_after_init/usr/local/lib/python3.5/dist-packages/v4l2.py.iotpatch new file mode 100644 index 00000000000..5cc87a78c88 --- /dev/null +++ b/addons/point_of_sale/tools/posbox/overwrite_after_init/usr/local/lib/python3.5/dist-packages/v4l2.py.iotpatch @@ -0,0 +1,20 @@ +--- v4l2_old.py 2019-01-23 12:49:01.081564000 +0100 ++++ v4l2_new.py 2019-01-23 12:50:32.369730171 +0100 +@@ -194,7 +194,7 @@ + V4L2_BUF_TYPE_SLICED_VBI_OUTPUT, + V4L2_BUF_TYPE_VIDEO_OUTPUT_OVERLAY, + V4L2_BUF_TYPE_PRIVATE, +-) = range(1, 9) + [0x80] ++) = list(range(1, 9)) + [0x80] + + + v4l2_ctrl_type = enum +@@ -245,7 +245,7 @@ + V4L2_PRIORITY_INTERACTIVE, + V4L2_PRIORITY_RECORD, + V4L2_PRIORITY_DEFAULT, +-) = range(0, 4) + [2] ++) = list(range(0, 4)) + [2] + + + class v4l2_rect(ctypes.Structure): diff --git a/addons/point_of_sale/tools/posbox/overwrite_before_init/etc/init_posbox_image.sh b/addons/point_of_sale/tools/posbox/overwrite_before_init/etc/init_posbox_image.sh index 85c1933f096..53ab15a8748 100755 --- a/addons/point_of_sale/tools/posbox/overwrite_before_init/etc/init_posbox_image.sh +++ b/addons/point_of_sale/tools/posbox/overwrite_before_init/etc/init_posbox_image.sh @@ -28,6 +28,8 @@ PKGS_TO_INSTALL=" cups \ printer-driver-all \ cups-ipp-utils \ + libcups2-dev \ + pcscd \ localepurge \ vim \ mc \ @@ -37,6 +39,7 @@ PKGS_TO_INSTALL=" hostapd \ git \ rsync \ + swig \ console-data \ lightdm \ xserver-xorg-video-fbdev \ @@ -49,7 +52,9 @@ PKGS_TO_INSTALL=" rpi-update \ adduser \ postgresql \ + python-cups \ python3 \ + python3-pyscard \ python3-urllib3 \ python3-dateutil \ python3-decorator \ @@ -104,9 +109,14 @@ rm -rf /usr/share/doc # this may be fixed with libusb>2:1.0.11-1, but that's the most recent one in raspbian # so we install the latest pyusb that works with this libusb. # Even in stretch, we had an error with langid (but worked otherwise) -pip3 install pyusb==1.0.0b1 -pip3 install evdev -pip3 install gatt +PIP_TO_INSTALL=" + pyusb==1.0.0b1 \ + evdev \ + gatt \ + v4l2 \ + pycups" + +pip3 install ${PIP_TO_INSTALL} groupadd usbusers @@ -148,6 +158,7 @@ if [ $SYSTEMD -eq 1 ]; then systemctl set-default graphical.target ln -fs /etc/systemd/system/autologin@.service /etc/systemd/system/getty.target.wants/getty@tty1.service rm /etc/systemd/system/sysinit.target.wants/systemd-timesyncd.service + rm /etc/systemd/system/hostapd.service else update-rc.d lightdm enable 2 fi diff --git a/addons/point_of_sale/tools/posbox/posbox_create_image.sh b/addons/point_of_sale/tools/posbox/posbox_create_image.sh index 0c517da73d7..6401444200c 100755 --- a/addons/point_of_sale/tools/posbox/posbox_create_image.sh +++ b/addons/point_of_sale/tools/posbox/posbox_create_image.sh @@ -120,6 +120,14 @@ qemu-system-arm "${QEMU_OPTS[@]}" mount "${LOOP_MAPPER_PATH}" "${MOUNT_POINT}" cp -av "${OVERWRITE_FILES_AFTER_INIT_DIR}"/* "${MOUNT_POINT}" +find "${MOUNT_POINT}"/usr -type f -name "*.iotpatch"|while read iotpatch; do + DIR=$(dirname "${iotpatch}") + BASE=$(basename "${iotpatch%.iotpatch}") + find "${DIR}" -type f -name "${BASE}" ! -name "*.iotpatch"|while read file; do + patch -f "${file}" < "${iotpatch}" + done +done + # cleanup sleep 2 umount "${MOUNT_POINT}" diff --git a/addons/point_of_sale/tools/posbox/posbox_download_images.sh b/addons/point_of_sale/tools/posbox/posbox_download_images.sh index b13505ce16b..939e9879c2c 100755 --- a/addons/point_of_sale/tools/posbox/posbox_download_images.sh +++ b/addons/point_of_sale/tools/posbox/posbox_download_images.sh @@ -1,8 +1,7 @@ #!/bin/sh -wget 'http://downloads.raspberrypi.org/raspbian_lite/images/raspbian_lite-2018-06-29/2018-06-27-raspbian-stretch-lite.zip' -O raspbian.img.zip +wget 'http://downloads.raspberrypi.org/raspbian_lite/images/raspbian_lite-2018-11-15/2018-11-13-raspbian-stretch-lite.zip' -O raspbian.img.zip unzip raspbian.img.zip -wget 'https://github.com/dhruvvyas90/qemu-rpi-kernel/raw/master/kernel-qemu-4.9.59-stretch' -O kernel-qemu +wget 'https://github.com/dhruvvyas90/qemu-rpi-kernel/raw/master/kernel-qemu-4.14.79-stretch' -O kernel-qemu wget 'https://github.com/dhruvvyas90/qemu-rpi-kernel/raw/master/versatile-pb.dtb' - diff --git a/addons/pos_hr/__manifest__.py b/addons/pos_hr/__manifest__.py index a741ea0cd2c..170529048ad 100644 --- a/addons/pos_hr/__manifest__.py +++ b/addons/pos_hr/__manifest__.py @@ -20,6 +20,6 @@ 'views/pos_order_report_view.xml', ], 'installable': True, - 'auto-install': True, + 'auto_install': True, 'qweb': ['static/src/xml/pos.xml'], } diff --git a/addons/product/models/product.py b/addons/product/models/product.py index 1979ccda68c..99a314662dd 100644 --- a/addons/product/models/product.py +++ b/addons/product/models/product.py @@ -15,6 +15,7 @@ from odoo.tools import float_compare _logger = logging.getLogger(__name__) + class ProductCategory(models.Model): _name = "product.category" _description = "Product Category" @@ -612,7 +613,10 @@ class ProductProduct(models.Model): precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') res = self.env['product.supplierinfo'] - for seller in self._prepare_sellers(params): + sellers = self._prepare_sellers(params) + if self.env.context.get('force_company'): + sellers = sellers.filtered(lambda s: not s.company_id or s.company_id.id == self.env.context['force_company']) + for seller in sellers: # Set quantity in UoM of seller quantity_uom_seller = quantity if quantity_uom_seller and uom_id and uom_id != seller.product_uom: diff --git a/addons/product/models/product_template.py b/addons/product/models/product_template.py index 959a0de6e2e..56f9e10a840 100644 --- a/addons/product/models/product_template.py +++ b/addons/product/models/product_template.py @@ -422,7 +422,7 @@ class ProductTemplate(models.Model): # we need to add the base _name_search to the results # FIXME awa: this is really not performant at all but after discussing with the team # we don't see another way to do it - if len(searched_ids) < limit: + if not limit or len(searched_ids) < limit: searched_ids |= set([template_id[0] for template_id in super(ProductTemplate, self)._name_search( name, @@ -523,6 +523,7 @@ class ProductTemplate(models.Model): variants_to_create.append({ 'product_tmpl_id': tmpl_id.id, 'attribute_value_ids': [(6, 0, list(value_ids))], + 'active': tmpl_id.active, }) if len(variants_to_create) > 1000: raise UserError(_( @@ -991,8 +992,10 @@ class ProductTemplate(models.Model): if not self.active: return _("The product template is archived so no combination is possible.") - ptal_stack = [self._get_valid_product_template_attribute_lines()] - combination_stack = [self.env['product.template.attribute.value']] + necessary_values = necessary_values or self.env['product.template.attribute.value'] + necessary_attributes = necessary_values.mapped('attribute_id') + ptal_stack = [self.valid_product_template_attribute_line_ids.filtered(lambda ptal: ptal.attribute_id not in necessary_attributes)] + combination_stack = [necessary_values] # keep going while we have attribute lines to test while len(ptal_stack): @@ -1001,7 +1004,7 @@ class ProductTemplate(models.Model): if not attribute_lines: # full combination, if it's possible return it, otherwise skip it - if self._is_combination_possible(combination, parent_combination) and all(v in combination for v in (necessary_values or [])): + if self._is_combination_possible(combination, parent_combination): yield(combination) else: # we have remaining attribute lines to consider diff --git a/addons/product/tests/test_product_attribute_value_config.py b/addons/product/tests/test_product_attribute_value_config.py index aa1de67d16b..e966adbdbdc 100644 --- a/addons/product/tests/test_product_attribute_value_config.py +++ b/addons/product/tests/test_product_attribute_value_config.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +import time + from odoo.tests import tagged from odoo.tests.common import TransactionCase @@ -423,6 +425,47 @@ class TestProductAttributeValueConfig(TestProductAttributeValueSetup): self.assertEqual(self.computer._get_closest_possible_combination(computer_ssd_256 + computer_ram_8 + computer_hdd_4 + computer_hdd_2), computer_ssd_256 + computer_ram_8 + computer_hdd_4) + # Make sure this is not extremely slow: + product_template = self.env['product.template'].create({ + 'name': 'many combinations', + }) + + for i in range(10): + # create the attributes + product_attribute = self.env['product.attribute'].create({ + 'name': "att %s" % i, + 'create_variant': 'dynamic', + 'sequence': i, + }) + + for j in range(10): + # create the attribute values + self.env['product.attribute.value'].create([{ + 'name': "val %s/%s" % (i, j), + 'attribute_id': product_attribute.id, + 'sequence': j, + }]) + + # set attribute and attribute values on the template + self.env['product.template.attribute.line'].create([{ + 'attribute_id': product_attribute.id, + 'product_tmpl_id': product_template.id, + 'value_ids': [(6, 0, product_attribute.value_ids.ids)] + }]) + + # Get a value in the middle for each attribute to make sure it would + # take time to reach it (if looping one by one like before the fix). + combination = self.env['product.template.attribute.value'] + for ptal in product_template.attribute_line_ids: + combination += ptal.product_template_value_ids[5] + + started_at = time.time() + self.assertEqual(product_template._get_closest_possible_combination(combination), combination) + elapsed = time.time() - started_at + # It should take around 10ms, but to avoid false positives we check an + # higher value. Before the fix it would take hours. + self.assertLess(elapsed, 0.5) + def test_clear_caches(self): """The goal of this test is to make sure the cache is invalidated when it should be.""" diff --git a/addons/product/views/product_views.xml b/addons/product/views/product_views.xml index 5aab1bd8977..cac587a3fa7 100644 --- a/addons/product/views/product_views.xml +++ b/addons/product/views/product_views.xml @@ -300,8 +300,9 @@ <div class="o_row col-5 pl-0"> <field name="lst_price" widget='monetary' options="{'currency_field': 'currency_id', 'field_digits': True}" attrs="{'readonly': [('product_variant_count', '>', 1)]}"/> </div> - <field name="standard_price" widget='monetary' options="{'currency_field': 'currency_id'}"/> + <field name="standard_price" widget='monetary' options="{'currency_field': 'cost_currency_id'}"/> <field name="currency_id" invisible='1'/> + <field name="cost_currency_id" invisible="1"/> </group> </group> <group> @@ -618,7 +619,7 @@ <field name="arch" type="xml"> <form string="Vendor Information"> <group> - <group string="Vendor"> + <group name="vendor" string="Vendor"> <field name="product_variant_count" invisible="1"/> <field name="product_id" groups="product.group_product_variant" domain="[('product_tmpl_id', '=', product_tmpl_id)]" options="{'no_create_edit': True}"/> <field name="name" context="{'default_customer': 0, 'search_default_supplier': 1, 'default_supplier': 1}"/> diff --git a/addons/purchase/models/account_invoice.py b/addons/purchase/models/account_invoice.py index 9f63039afd7..6c7b1b9293d 100644 --- a/addons/purchase/models/account_invoice.py +++ b/addons/purchase/models/account_invoice.py @@ -25,6 +25,10 @@ class AccountInvoice(models.Model): return {} self.purchase_id = self.vendor_bill_purchase_id.purchase_order_id self.vendor_bill_id = self.vendor_bill_purchase_id.vendor_bill_id + vendor_ref = self.vendor_bill_purchase_id.purchase_order_id.partner_ref + if vendor_ref: + self.reference = ", ".join([self.reference, vendor_ref]) if ( + self.reference and vendor_ref not in self.reference) else vendor_ref self.vendor_bill_purchase_id = False return {} diff --git a/addons/purchase/models/purchase.py b/addons/purchase/models/purchase.py index 4aa4100587d..773d8962d49 100644 --- a/addons/purchase/models/purchase.py +++ b/addons/purchase/models/purchase.py @@ -192,7 +192,7 @@ class PurchaseOrder(models.Model): if not self.partner_id: self.fiscal_position_id = False self.payment_term_id = False - self.currency_id = False + self.currency_id = self.env.user.company_id.currency_id.id else: self.fiscal_position_id = self.env['account.fiscal.position'].with_context(company_id=self.company_id.id).get_fiscal_position(self.partner_id.id) self.payment_term_id = self.partner_id.property_supplier_payment_term_id.id diff --git a/addons/purchase/views/purchase_views.xml b/addons/purchase/views/purchase_views.xml index 8c9492714f0..9809112d2f7 100644 --- a/addons/purchase/views/purchase_views.xml +++ b/addons/purchase/views/purchase_views.xml @@ -160,8 +160,8 @@ </button> </div> <div class="oe_title"> - <span class="o_form_label" string="Request for Quotation " attrs="{'invisible': [('state','not in',('draft','sent'))]}"/> - <span class="o_form_label" string="Purchase Order " attrs="{'invisible': [('state','in',('draft','sent'))]}"/> + <span class="o_form_label" attrs="{'invisible': [('state','not in',('draft','sent'))]}">Request for Quotation </span> + <span class="o_form_label" attrs="{'invisible': [('state','in',('draft','sent'))]}">Purchase Order </span> <h1> <field name="name" readonly="1"/> </h1> diff --git a/addons/purchase_mrp_subcontracting/__init__.py b/addons/purchase_mrp_subcontracting/__init__.py new file mode 100644 index 00000000000..dc5e6b693d1 --- /dev/null +++ b/addons/purchase_mrp_subcontracting/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models diff --git a/addons/purchase_mrp_subcontracting/__manifest__.py b/addons/purchase_mrp_subcontracting/__manifest__.py new file mode 100644 index 00000000000..5fca667e462 --- /dev/null +++ b/addons/purchase_mrp_subcontracting/__manifest__.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + + +{ + 'name': 'Purchase and Subcontracting Management', + 'version': '0.1', + 'category': 'Hidden', + 'description': """ + This bridge module allows to manage subcontracting with the purchase module. + """, + 'depends': ['purchase_stock', 'mrp_subcontracting'], + 'data': [ + 'views/supplier_info_views.xml', + ], + 'installable': True, + 'auto_install': True, +} diff --git a/addons/purchase_mrp_subcontracting/models/__init__.py b/addons/purchase_mrp_subcontracting/models/__init__.py new file mode 100644 index 00000000000..8585b3cca70 --- /dev/null +++ b/addons/purchase_mrp_subcontracting/models/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import product +from . import stock_picking diff --git a/addons/purchase_mrp_subcontracting/models/product.py b/addons/purchase_mrp_subcontracting/models/product.py new file mode 100644 index 00000000000..18bd7a53bd5 --- /dev/null +++ b/addons/purchase_mrp_subcontracting/models/product.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +class SupplierInfo(models.Model): + _inherit = 'product.supplierinfo' + + is_subcontractor = fields.Boolean('Subcontracted', compute='_compute_is_subcontractor', help="Choose a vendor of type subcontractor if you want to subcontract the product") + + @api.depends('name') + def _compute_is_subcontractor(self): + for supplierinfo in self: + supplierinfo.is_subcontractor = True if supplierinfo.name.type == 'subcontractor' else False + diff --git a/addons/purchase_mrp_subcontracting/models/stock_picking.py b/addons/purchase_mrp_subcontracting/models/stock_picking.py new file mode 100644 index 00000000000..57c4441b54e --- /dev/null +++ b/addons/purchase_mrp_subcontracting/models/stock_picking.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError + + +class StockPicking(models.Model): + _inherit = 'stock.picking' + + def _prepare_subcontract_mo_vals(self, subcontract_move, bom): + vals = super(StockPicking, self)._prepare_subcontract_mo_vals(subcontract_move, bom) + if bom.product_tmpl_id.cost_method in ('fifo', 'average'): + vals = dict(vals, extra_cost=subcontract_move._get_price_unit()) + return vals + diff --git a/addons/purchase_mrp_subcontracting/tests/__init__.py b/addons/purchase_mrp_subcontracting/tests/__init__.py new file mode 100644 index 00000000000..76fe74ec58b --- /dev/null +++ b/addons/purchase_mrp_subcontracting/tests/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- + +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_purchase_subcontracting + diff --git a/addons/purchase_mrp_subcontracting/tests/test_purchase_subcontracting.py b/addons/purchase_mrp_subcontracting/tests/test_purchase_subcontracting.py new file mode 100644 index 00000000000..5510d1127dd --- /dev/null +++ b/addons/purchase_mrp_subcontracting/tests/test_purchase_subcontracting.py @@ -0,0 +1,113 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import datetime + +from odoo.tests import Form +from odoo.tests.common import TransactionCase +from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT + + +class TestPurchaseSubcontractingFlows(TransactionCase): + def setUp(self): + super(TestPurchaseSubcontractingFlows, self).setUp() + + # 1. Create a BOM of subcontracting type + self.comp1 = self.env['product.product'].create({ + 'name': 'Component1', + 'type': 'product', + 'categ_id': self.env.ref('product.product_category_all').id, + }) + self.comp2 = self.env['product.product'].create({ + 'name': 'Component2', + 'type': 'product', + 'categ_id': self.env.ref('product.product_category_all').id, + }) + self.finished_product = self.env['product.product'].create({ + 'name': 'finished', + 'type': 'product', + 'categ_id': self.env.ref('product.product_category_all').id, + }) + bom_form = Form(self.env['mrp.bom']) + bom_form.type = 'subcontract' + bom_form.product_tmpl_id = self.finished_product.product_tmpl_id + with bom_form.bom_line_ids.new() as bom_line: + bom_line.product_id = self.comp1 + bom_line.product_qty = 1 + with bom_form.bom_line_ids.new() as bom_line: + bom_line.product_id = self.comp2 + bom_line.product_qty = 1 + self.bom_finished_product = bom_form.save() + + # 2: Create a subcontracting partner + company_partner = self.env['res.partner'].create({'name': 'main_partner', 'is_company': True}) + self.subcontractor_partner1 = self.env['res.partner'].create({ + 'name': 'subcontractor_partner', + 'type': 'subcontractor', + 'parent_id': company_partner.id, + }) + + self.stock_location = self.env.ref('stock.stock_location_stock') + self.customer_location = self.env.ref('stock.stock_location_customers') + self.supplier_location = self.env.ref('stock.stock_location_suppliers') + self.uom_unit = self.env.ref('uom.product_uom_unit') + + def test_purchase_1(self): + self.env.ref('product.product_category_all').property_cost_method = 'fifo' + + # IN 10@10 comp1 10@20 comp2 + move1 = self.env['stock.move'].create({ + 'name': 'IN 10 units @ 10.00 per unit', + 'location_id': self.supplier_location.id, + 'location_dest_id': self.env.user.company_id.subcontracting_location_id.id, + 'product_id': self.comp1.id, + 'product_uom': self.uom_unit.id, + 'product_uom_qty': 10.0, + 'price_unit': 10.0, + }) + move1._action_confirm() + move1._action_assign() + move1.move_line_ids.qty_done = 10.0 + move1._action_done() + move1 = self.env['stock.move'].create({ + 'name': 'IN 10 units @ 20.00 per unit', + 'location_id': self.supplier_location.id, + 'location_dest_id': self.env.user.company_id.subcontracting_location_id.id, + 'product_id': self.comp2.id, + 'product_uom': self.uom_unit.id, + 'product_uom_qty': 10.0, + 'price_unit': 20.0, + }) + move1._action_confirm() + move1._action_assign() + move1.move_line_ids.qty_done = 10.0 + move1._action_done() + + po1 = self.env['purchase.order'].create({ + 'partner_id': self.subcontractor_partner1.id, + 'order_line': [ + (0, 0, { + 'name': self.finished_product.name, + 'product_id': self.finished_product.id, + 'product_qty': 1, + 'product_uom': self.finished_product.uom_po_id.id, + 'price_unit': 30.0, + 'date_planned': datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT), + }), + ], + }) + po1.button_confirm() + + picking1 = po1.picking_ids[0] + + # Check the created manufacturing order + mo = self.env['mrp.production'].search([('bom_id', '=', self.bom_finished_product.id)]) + self.assertEqual(len(mo), 1) + + move1 = picking1.move_lines[0] + move1.quantity_done = 1 + picking1.button_validate() + self.assertEqual(mo.move_finished_ids.value, 60) + # waiting forward port of 67ea26460ff27d6a + # self.assertEqual(mo.move_finished_ids.product_id.stock_value, 60) + diff --git a/addons/purchase_mrp_subcontracting/views/supplier_info_views.xml b/addons/purchase_mrp_subcontracting/views/supplier_info_views.xml new file mode 100644 index 00000000000..65a6ad3ac98 --- /dev/null +++ b/addons/purchase_mrp_subcontracting/views/supplier_info_views.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <record id="product_supplierinfo_subcontractor_tree_view" model="ir.ui.view"> + <field name="name">product.supplierinfo.subcontractor.tree.view</field> + <field name="model">product.supplierinfo</field> + <field name="inherit_id" ref="product.product_supplierinfo_tree_view"/> + <field name="arch" type="xml"> + <xpath expr="//field[@name='name']" position="after"> + <field name="is_subcontractor" readonly="1"/> + </xpath> + </field> + </record> + + <record id="product_supplierinfo_subcontracting_form_view" model="ir.ui.view"> + <field name="name">product.supplierinfo.subcontracting.form.view</field> + <field name="model">product.supplierinfo</field> + <field name="inherit_id" ref="product.product_supplierinfo_form_view"/> + <field name="arch" type="xml"> + <xpath expr="//group[@name='vendor']" position="inside"> + <field name="is_subcontractor" readonly="1"/> + </xpath> + </field> + </record> +</odoo> + diff --git a/addons/purchase_requisition/models/product.py b/addons/purchase_requisition/models/product.py index ef94ad283b7..48380f1c3dc 100644 --- a/addons/purchase_requisition/models/product.py +++ b/addons/purchase_requisition/models/product.py @@ -16,10 +16,11 @@ class ProductProduct(models.Model): _inherit = 'product.product' def _prepare_sellers(self, params): + sellers = super(ProductProduct, self)._prepare_sellers(params) if params and params.get('order_id'): - return self.seller_ids.filtered(lambda s: not s.purchase_requisition_id or s.purchase_requisition_id == params['order_id'].requisition_id) + return sellers.filtered(lambda s: not s.purchase_requisition_id or s.purchase_requisition_id == params['order_id'].requisition_id) else: - return self.seller_ids + return sellers class ProductTemplate(models.Model): diff --git a/addons/purchase_stock/models/account_invoice.py b/addons/purchase_stock/models/account_invoice.py index 54c9bdc19fb..efb494777cd 100644 --- a/addons/purchase_stock/models/account_invoice.py +++ b/addons/purchase_stock/models/account_invoice.py @@ -123,7 +123,7 @@ class AccountInvoice(models.Model): diff_line = { 'type': 'src', 'name': i_line.name[:64], - 'price_unit': inv.currency_id.round(price_unit_val_dif), + 'price_unit': price_unit_val_dif, 'quantity': line_quantity, 'price': inv.currency_id.round(price_val_dif), 'account_id': acc, @@ -133,8 +133,9 @@ class AccountInvoice(models.Model): 'tax_ids': tax_ids, } # We update the original line accordingly - line['price_unit'] = inv.currency_id.round(line['price_unit'] - diff_line['price_unit']) + line['price_unit'] = line['price_unit'] - diff_line['price_unit'] line['price'] = inv.currency_id.round(line['quantity'] * line['price_unit']) + line['price_unit'] = inv.currency_id.round(line['price_unit']) diff_res.append(diff_line) return diff_res return [] diff --git a/addons/purchase_stock/models/purchase.py b/addons/purchase_stock/models/purchase.py index 2a3a4f54492..720039e214c 100644 --- a/addons/purchase_stock/models/purchase.py +++ b/addons/purchase_stock/models/purchase.py @@ -247,6 +247,12 @@ class PurchaseOrderLine(models.Model): if move.location_dest_id.usage == "supplier": if move.to_refund: total -= move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom) + elif move.origin_returned_move_id._is_dropshipped() and not move._is_dropshipped_returned(): + # Edge case: the dropship is returned to the stock, no to the supplier. + # In this case, the received quantity on the PO is set although we didn't + # receive the product physically in our stock. To avoid counting the + # quantity twice, we do nothing. + pass else: total += move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom) line.qty_received = total diff --git a/addons/purchase_stock/models/stock_rule.py b/addons/purchase_stock/models/stock_rule.py index 64a72467b11..e8873375ba9 100644 --- a/addons/purchase_stock/models/stock_rule.py +++ b/addons/purchase_stock/models/stock_rule.py @@ -174,7 +174,7 @@ class StockRule(models.Model): def _update_purchase_order_line(self, product_id, product_qty, product_uom, company_id, values, line): partner = values['supplier'].name procurement_uom_po_qty = product_uom._compute_quantity(product_qty, product_id.uom_po_id) - seller = product_id._select_seller( + seller = product_id.with_context(force_company=company_id.id)._select_seller( partner_id=partner, quantity=line.product_qty + procurement_uom_po_qty, date=line.order_id.date_order and line.order_id.date_order.date(), @@ -197,7 +197,7 @@ class StockRule(models.Model): procurement_uom_po_qty = product_uom._compute_quantity(product_qty, product_id.uom_po_id) # _select_seller is used if the supplier have different price depending # the quantities ordered. - seller = product_id._select_seller( + seller = product_id.with_context(force_company=company_id.id)._select_seller( partner_id=partner, quantity=procurement_uom_po_qty, date=po.date_order and po.date_order.date(), diff --git a/addons/sale/models/res_company.py b/addons/sale/models/res_company.py index 6d6cfccc87c..439061adafa 100644 --- a/addons/sale/models/res_company.py +++ b/addons/sale/models/res_company.py @@ -61,6 +61,7 @@ class ResCompany(models.Model): 'product_uom_qty': 10, 'price_unit': 123, 'order_id': sample_sales_order.id, + 'company_id': sample_sales_order.company_id.id, }) return sample_sales_order diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py index d74dc58412b..773f3e4d87d 100644 --- a/addons/sale/models/sale.py +++ b/addons/sale/models/sale.py @@ -1031,20 +1031,23 @@ class SaleOrderLine(models.Model): res[field] = line._fields[field].convert_to_write(line[field], line) return res - @api.model - def create(self, values): - if values.get('display_type', self.default_get(['display_type'])['display_type']): - values.update(product_id=False, price_unit=0, product_uom_qty=0, product_uom=False, customer_lead=0) - - values.update(self._prepare_add_missing_fields(values)) - line = super(SaleOrderLine, self).create(values) - if line.product_id and line.order_id.state == 'sale': - msg = _("Extra line with %s ") % (line.product_id.display_name,) - line.order_id.message_post(body=msg) - # create an analytic account if at least an expense product - if line.product_id.expense_policy not in [False, 'no'] and not self.order_id.analytic_account_id: - self.order_id._create_analytic_account() - return line + @api.model_create_multi + def create(self, vals_list): + for values in vals_list: + if values.get('display_type', self.default_get(['display_type'])['display_type']): + values.update(product_id=False, price_unit=0, product_uom_qty=0, product_uom=False, customer_lead=0) + + values.update(self._prepare_add_missing_fields(values)) + + lines = super().create(vals_list) + for line in lines: + if line.product_id and line.order_id.state == 'sale': + msg = _("Extra line with %s ") % (line.product_id.display_name,) + line.order_id.message_post(body=msg) + # create an analytic account if at least an expense product + if line.product_id.expense_policy not in [False, 'no'] and not line.order_id.analytic_account_id: + line.order_id._create_analytic_account() + return lines _sql_constraints = [ ('accountable_required_fields', diff --git a/addons/sale/report/sale_report_templates.xml b/addons/sale/report/sale_report_templates.xml index 3991d063cbc..57331c66300 100644 --- a/addons/sale/report/sale_report_templates.xml +++ b/addons/sale/report/sale_report_templates.xml @@ -66,26 +66,31 @@ <table class="table table-sm o_main_table"> <thead> <tr> + <!-- TODO: remove in master --> + <t t-set="colspan" t-value="5"/> <th class="text-left">Description</th> <th class="text-right">Quantity</th> <th class="text-right">Unit Price</th> <th t-if="display_discount" class="text-right" groups="sale.group_discount_per_so_line"> <span>Disc.%</span> + <!-- TODO: remove in master --> + <t t-set="colspan" t-value="colspan+1"/> </th> <th class="text-right">Taxes</th> - <th class="text-right" groups="account.group_show_line_subtotals_tax_excluded">Amount</th> - <th class="text-right" groups="account.group_show_line_subtotals_tax_included">Total Price</th> + <th class="text-right"> + <t groups="account.group_show_line_subtotals_tax_excluded">Amount</t> + <t groups="account.group_show_line_subtotals_tax_included">Total Price</t> + </th> </tr> </thead> <tbody class="sale_tbody"> - <t t-set="current_subtotal_tax_excluded" t-value="0"/> - <t t-set="current_subtotal_tax_included" t-value="0"/> + <t t-set="current_subtotal" t-value="0"/> <t t-foreach="doc.order_line" t-as="line"> - <t t-set="current_subtotal_tax_excluded" t-value="current_subtotal_tax_excluded + line.price_subtotal" groups="account.group_show_line_subtotals_tax_excluded"/> - <t t-set="current_subtotal_tax_included" t-value="current_subtotal_tax_included + line.price_total" groups="account.group_show_line_subtotals_tax_included"/> + <t t-set="current_subtotal" t-value="current_subtotal + line.price_subtotal" groups="account.group_show_line_subtotals_tax_excluded"/> + <t t-set="current_subtotal" t-value="current_subtotal + line.price_total" groups="account.group_show_line_subtotals_tax_included"/> <tr t-att-class="'bg-200 font-weight-bold o_line_section' if line.display_type == 'line_section' else 'font-italic o_line_note' if line.display_type == 'line_note' else ''"> <t t-if="not line.display_type"> @@ -103,11 +108,9 @@ <td class="text-right"> <span t-esc="', '.join(map(lambda x: (x.description or x.name), line.tax_id))"/> </td> - <td class="text-right o_price_total" groups="account.group_show_line_subtotals_tax_excluded"> - <span t-field="line.price_subtotal"/> - </td> - <td class="text-right o_price_total" groups="account.group_show_line_subtotals_tax_included"> - <span t-field="line.price_total"/> + <td class="text-right o_price_total"> + <span t-field="line.price_subtotal" groups="account.group_show_line_subtotals_tax_excluded"/> + <span t-field="line.price_total" groups="account.group_show_line_subtotals_tax_included"/> </td> </t> <t t-if="line.display_type == 'line_section'"> @@ -115,8 +118,7 @@ <span t-field="line.name"/> </td> <t t-set="current_section" t-value="line"/> - <t t-set="current_subtotal_tax_excluded" t-value="0"/> - <t t-set="current_subtotal_tax_included" t-value="0"/> + <t t-set="current_subtotal" t-value="0"/> </t> <t t-if="line.display_type == 'line_note'"> <td colspan="99"> @@ -127,17 +129,12 @@ <t t-if="current_section and (line_last or doc.order_line[line_index+1].display_type == 'line_section')"> <tr class="is-subtotal text-right"> - <td colspan="99" groups="account.group_show_line_subtotals_tax_excluded,account.group_show_line_subtotals_tax_included"> + <td colspan="99"> <strong class="mr16">Subtotal</strong> <span - groups="account.group_show_line_subtotals_tax_excluded" - t-esc="current_subtotal_tax_excluded" + t-esc="current_subtotal" t-options='{"widget": "monetary", "display_currency": doc.pricelist_id.currency_id}' /> - <span groups="account.group_show_line_subtotals_tax_included"> | <span - t-esc="current_subtotal_tax_included" - t-options='{"widget": "monetary", "display_currency": doc.pricelist_id.currency_id}' - /></span> </td> </tr> </t> diff --git a/addons/sale/static/src/js/tour.js b/addons/sale/static/src/js/tour.js index 7e5c4851446..4c97755017e 100644 --- a/addons/sale/static/src/js/tour.js +++ b/addons/sale/static/src/js/tour.js @@ -44,7 +44,8 @@ tour.register('sale_tour', { content: _t("Select a product, or create a new one on the fly."), position: "right", run: function (actions) { - actions.text("DESK0001", this.$anchor.find('input')); + var $input = this.$anchor.find('input'); + actions.text("DESK0001", $input.length === 0 ? this.$anchor : $input); // fake keydown to trigger search var keyDownEvent = jQuery.Event("keydown"); keyDownEvent.which = 42; diff --git a/addons/sale/views/sale_portal_templates.xml b/addons/sale/views/sale_portal_templates.xml index 4e1d82f36b7..3f59a26dd3d 100644 --- a/addons/sale/views/sale_portal_templates.xml +++ b/addons/sale/views/sale_portal_templates.xml @@ -426,26 +426,31 @@ <table class="table table-sm" id="sales_order_table"> <thead class="bg-100"> <tr> + <!-- TODO: remove in master --> + <t t-set="colspan" t-value="6"/> <th class="text-left">Products</th> <th class="text-right">Quantity</th> <th t-attf-class="text-right {{ 'd-none d-sm-table-cell' if report_type == 'html' else '' }}">Unit Price</th> <th t-if="display_discount" t-attf-class="text-right {{ 'd-none d-sm-table-cell' if report_type == 'html' else '' }}"> <span>Disc.%</span> + <!-- TODO: remove in master --> + <t t-set="colspan" t-value="colspan+1"/> </th> <th t-attf-class="text-right {{ 'd-none d-md-table-cell' if report_type == 'html' else '' }}">Taxes</th> - <th class="text-right" groups="account.group_show_line_subtotals_tax_excluded">Amount</th> - <th class="text-right" groups="account.group_show_line_subtotals_tax_included">Total Price</th> + <th class="text-right" > + <span groups="account.group_show_line_subtotals_tax_excluded">Amount</span> + <span groups="account.group_show_line_subtotals_tax_included">Total Price</span> + </th> </tr> </thead> <tbody class="sale_tbody"> - <t t-set="current_subtotal_tax_excluded" t-value="0"/> - <t t-set="current_subtotal_tax_included" t-value="0"/> + <t t-set="current_subtotal" t-value="0"/> <t t-foreach="sale_order.order_line" t-as="line"> - <t t-set="current_subtotal_tax_excluded" t-value="current_subtotal_tax_excluded + line.price_subtotal" groups="account.group_show_line_subtotals_tax_excluded"/> - <t t-set="current_subtotal_tax_included" t-value="current_subtotal_tax_included + line.price_total" groups="account.group_show_line_subtotals_tax_included"/> + <t t-set="current_subtotal" t-value="current_subtotal + line.price_subtotal" groups="account.group_show_line_subtotals_tax_excluded"/> + <t t-set="current_subtotal" t-value="current_subtotal + line.price_total" groups="account.group_show_line_subtotals_tax_included"/> <tr t-att-class="'bg-200 font-weight-bold o_line_section' if line.display_type == 'line_section' else 'font-italic o_line_note' if line.display_type == 'line_note' else ''"> <t t-if="not line.display_type"> @@ -476,11 +481,9 @@ <td t-attf-class="text-right {{ 'd-none d-md-table-cell' if report_type == 'html' else '' }}"> <span t-esc="', '.join(map(lambda x: (x.description or x.name), line.tax_id))"/> </td> - <td class="text-right o_price_total" groups="account.group_show_line_subtotals_tax_excluded"> - <span t-field="line.price_subtotal"/> - </td> - <td class="text-right o_price_total" groups="account.group_show_line_subtotals_tax_included"> - <span t-field="line.price_total"/> + <td class="text-right"> + <span t-field="line.price_subtotal" groups="account.group_show_line_subtotals_tax_excluded"/> + <span t-field="line.price_total" groups="account.group_show_line_subtotals_tax_included"/> </td> </t> <t t-if="line.display_type == 'line_section'"> @@ -488,8 +491,7 @@ <span t-field="line.name"/> </td> <t t-set="current_section" t-value="line"/> - <t t-set="current_subtotal_tax_excluded" t-value="0"/> - <t t-set="current_subtotal_tax_included" t-value="0"/> + <t t-set="current_subtotal" t-value="0"/> </t> <t t-if="line.display_type == 'line_note'"> <td colspan="99"> @@ -503,14 +505,9 @@ <td colspan="99"> <strong class="mr16">Subtotal</strong> <span - groups="account.group_show_line_subtotals_tax_excluded" - t-esc="current_subtotal_tax_excluded" - t-options='{"widget": "monetary", "display_currency": doc.pricelist_id.currency_id}' + t-esc="current_subtotal" + t-options='{"widget": "monetary", "display_currency": sale_order.pricelist_id.currency_id}' /> - <span groups="account.group_show_line_subtotals_tax_included"> | <span - t-esc="current_subtotal_tax_included" - t-options='{"widget": "monetary", "display_currency": doc.pricelist_id.currency_id}' - /></span> </td> </tr> </t> diff --git a/addons/sale_coupon/models/sale_coupon_program.py b/addons/sale_coupon/models/sale_coupon_program.py index f4669f2d3a9..d35734f098b 100644 --- a/addons/sale_coupon/models/sale_coupon_program.py +++ b/addons/sale_coupon/models/sale_coupon_program.py @@ -101,7 +101,7 @@ class SaleCouponProgram(models.Model): 'sale_ok': False, 'purchase_ok': False, 'invoice_policy': 'order', - 'lst_price': -1000000, # Prevent pricelist strikethrough as negative value will always be lower then default 1$ + 'lst_price': 0, #Do not set a high value to avoid issue with coupon code }) program.write({'discount_line_product_id': discount_line_product_id.id}) return program diff --git a/addons/sale_management/models/sale_order.py b/addons/sale_management/models/sale_order.py index 6ebed05a2bc..6a95dfeaeeb 100644 --- a/addons/sale_management/models/sale_order.py +++ b/addons/sale_management/models/sale_order.py @@ -31,7 +31,8 @@ class SaleOrder(models.Model): @api.onchange('partner_id') def onchange_partner_id(self): super(SaleOrder, self).onchange_partner_id() - self.note = self.sale_order_template_id.note or self.note + template = self.sale_order_template_id.with_context(lang=self.partner_id.lang) + self.note = template.note or self.note def _compute_line_data_for_template_change(self, line): return { diff --git a/addons/sale_management/models/sale_order_template.py b/addons/sale_management/models/sale_order_template.py index 8eef6efd405..493386f33c7 100644 --- a/addons/sale_management/models/sale_order_template.py +++ b/addons/sale_management/models/sale_order_template.py @@ -18,7 +18,7 @@ class SaleOrderTemplate(models.Model): name = fields.Char('Quotation Template', required=True) sale_order_template_line_ids = fields.One2many('sale.order.template.line', 'sale_order_template_id', 'Lines', copy=True) - note = fields.Text('Terms and conditions') + note = fields.Text('Terms and conditions', translate=True) sale_order_template_option_ids = fields.One2many('sale.order.template.option', 'sale_order_template_id', 'Optional Products', copy=True) number_of_days = fields.Integer('Quotation Duration', help='Number of days for the validity date computation of the quotation') diff --git a/addons/sale_management/views/sale_portal_templates.xml b/addons/sale_management/views/sale_portal_templates.xml index 2a04c93fbdb..ccbb69b8ac6 100644 --- a/addons/sale_management/views/sale_portal_templates.xml +++ b/addons/sale_management/views/sale_portal_templates.xml @@ -15,6 +15,7 @@ <xpath expr="//section[@id='details']//table[@id='sales_order_table']/thead/tr" position="inside"> <th t-if="display_remove"> + <!-- TODO: remove in master --> <t t-set="colspan" t-value="colspan+1"/> </th> </xpath> diff --git a/addons/sale_product_configurator/static/src/js/product_configurator_controller.js b/addons/sale_product_configurator/static/src/js/product_configurator_controller.js index 1b162f3f301..45cbbf63b72 100644 --- a/addons/sale_product_configurator/static/src/js/product_configurator_controller.js +++ b/addons/sale_product_configurator/static/src/js/product_configurator_controller.js @@ -292,7 +292,7 @@ var ProductConfiguratorFormController = FormController.extend({ * * @param {Array} attributeValueIds */ - _getAttributeValueIds(attributeValueIds) { + _getAttributeValueIds: function (attributeValueIds) { if (!attributeValueIds || attributeValueIds.length === 0){ return false; } diff --git a/addons/sale_timesheet/models/project_overview.py b/addons/sale_timesheet/models/project_overview.py index 86a70fd30d2..af275ba9a3b 100644 --- a/addons/sale_timesheet/models/project_overview.py +++ b/addons/sale_timesheet/models/project_overview.py @@ -321,7 +321,9 @@ class Project(models.Model): def _table_get_empty_so_lines(self): """ get the Sale Order Lines having no timesheet but having generated a task or a project """ so_lines = self.sudo().mapped('sale_line_id.order_id.order_line').filtered(lambda sol: sol.is_service and not sol.is_expense) - return set(so_lines.ids), set(so_lines.mapped('order_id').ids) + # include the service SO line of SO sharing the same project + sale_order = self.env['sale.order'].search([('project_id', 'in', self.ids)]) + return set(so_lines.ids) | set(sale_order.mapped('order_line').filtered(lambda sol: sol.is_service and not sol.is_expense).ids), set(so_lines.mapped('order_id').ids) | set(sale_order.ids) # -------------------------------------------------- # Actions: Stat buttons, ... diff --git a/addons/snailmail/static/tests/chatter_snailmail_tests.js b/addons/snailmail/static/tests/chatter_snailmail_tests.js index a56a0e51879..dc252017b5a 100644 --- a/addons/snailmail/static/tests/chatter_snailmail_tests.js +++ b/addons/snailmail/static/tests/chatter_snailmail_tests.js @@ -92,6 +92,10 @@ QUnit.module('Chatter', { res_id: 1, message_type: 'snailmail', }], + }, + "ir.attachment": { + fields: {}, + records: [], } }; }, diff --git a/addons/stock/models/res_company.py b/addons/stock/models/res_company.py index 838359ea63b..1854916bee3 100644 --- a/addons/stock/models/res_company.py +++ b/addons/stock/models/res_company.py @@ -126,13 +126,16 @@ class Company(models.Model): for company in company_without_property: company._create_scrap_location() + def _create_per_company_locations(self): + self.ensure_one() + self._create_transit_location() + self._create_inventory_loss_location() + self._create_production_location() + self._create_scrap_location() + @api.model def create(self, vals): company = super(Company, self).create(vals) - - company.sudo()._create_transit_location() - company.sudo()._create_inventory_loss_location() - company.sudo()._create_production_location() - company.sudo()._create_scrap_location() + company.sudo()._create_per_company_locations() self.env['stock.warehouse'].sudo().create({'name': company.name, 'code': company.name[:5], 'company_id': company.id, 'partner_id': company.partner_id.id}) return company diff --git a/addons/stock/models/stock_inventory.py b/addons/stock/models/stock_inventory.py index d3111d2564f..4710ff88851 100644 --- a/addons/stock/models/stock_inventory.py +++ b/addons/stock/models/stock_inventory.py @@ -3,7 +3,7 @@ from odoo import api, fields, models, _ from odoo.addons import decimal_precision as dp -from odoo.exceptions import UserError +from odoo.exceptions import UserError, ValidationError from odoo.tools import float_utils, float_compare @@ -151,13 +151,13 @@ class Inventory(models.Model): if self.filter == 'none' and self.product_id and self.location_id and self.lot_id: return if self.filter not in ('product', 'product_owner') and self.product_id: - raise UserError(_('The selected product doesn\'t belong to that owner..')) + raise ValidationError(_('The selected product doesn\'t belong to that owner..')) if self.filter != 'lot' and self.lot_id: - raise UserError(_('The selected lot number doesn\'t exist.')) + raise ValidationError(_('The selected lot number doesn\'t exist.')) if self.filter not in ('owner', 'product_owner') and self.partner_id: - raise UserError(_('The selected owner doesn\'t have the proprietary of that product.')) + raise ValidationError(_('The selected owner doesn\'t have the proprietary of that product.')) if self.filter != 'pack' and self.package_id: - raise UserError(_('The selected inventory options are not coherent, the package doesn\'t exist.')) + raise ValidationError(_('The selected inventory options are not coherent, the package doesn\'t exist.')) def action_reset_product_qty(self): self.mapped('line_ids').write({'product_qty': 0}) @@ -425,7 +425,7 @@ class InventoryLine(models.Model): """ for line in self: if line.product_id.type != 'product': - raise UserError(_("You can only adjust storable products.") + '\n\n%s -> %s' % (line.product_id.display_name, line.product_id.type)) + raise ValidationError(_("You can only adjust storable products.") + '\n\n%s -> %s' % (line.product_id.display_name, line.product_id.type)) def _get_move_values(self, qty, location_id, location_dest_id, out): self.ensure_one() diff --git a/addons/stock/models/stock_picking.py b/addons/stock/models/stock_picking.py index 281cc193eba..6576e00616d 100644 --- a/addons/stock/models/stock_picking.py +++ b/addons/stock/models/stock_picking.py @@ -492,8 +492,9 @@ class Picking(models.Model): # TDE FIXME: what ? # As the on_change in one2many list is WIP, we will overwrite the locations on the stock moves here # As it is a create the format will be a list of (0, 0, dict) - if vals.get('move_lines') and vals.get('location_id') and vals.get('location_dest_id'): - for move in vals['move_lines']: + moves = vals.get('move_lines') or vals.get('move_ids_without_package') + if moves and vals.get('location_id') and vals.get('location_dest_id'): + for move in moves: if len(move) == 3 and move[0] == 0: move[2]['location_id'] = vals['location_id'] move[2]['location_dest_id'] = vals['location_dest_id'] diff --git a/addons/stock/models/stock_production_lot.py b/addons/stock/models/stock_production_lot.py index b5636776ae3..43cbf15996c 100644 --- a/addons/stock/models/stock_production_lot.py +++ b/addons/stock/models/stock_production_lot.py @@ -28,13 +28,16 @@ class ProductionLot(models.Model): ('name_ref_uniq', 'unique (name, product_id)', 'The combination of serial number and product must be unique !'), ] - @api.model_create_multi - def create(self, vals_list): + def _check_create(self): active_picking_id = self.env.context.get('active_picking_id', False) if active_picking_id: picking_id = self.env['stock.picking'].browse(active_picking_id) if picking_id and not picking_id.picking_type_id.use_create_lots: raise UserError(_('You are not allowed to create a lot or serial number with this operation type. To change this, go on the operation type and tick the box "Create New Lots/Serial Numbers".')) + + @api.model_create_multi + def create(self, vals_list): + self._check_create() return super(ProductionLot, self).create(vals_list) @api.multi diff --git a/addons/stock/models/stock_quant.py b/addons/stock/models/stock_quant.py index 03c1890c51a..66a7aa90b8c 100644 --- a/addons/stock/models/stock_quant.py +++ b/addons/stock/models/stock_quant.py @@ -365,7 +365,7 @@ class QuantPackage(models.Model): 'res.partner', 'Owner', compute='_compute_package_info', search='_search_owner', index=True, readonly=True) - @api.depends('quant_ids.package_id', 'quant_ids.location_id', 'quant_ids.company_id', 'quant_ids.owner_id') + @api.depends('quant_ids.package_id', 'quant_ids.location_id', 'quant_ids.company_id', 'quant_ids.owner_id', 'quant_ids.quantity', 'quant_ids.reserved_quantity') def _compute_package_info(self): for package in self: values = {'location_id': False, 'company_id': self.env.user.company_id.id, 'owner_id': False} diff --git a/addons/stock/models/stock_warehouse.py b/addons/stock/models/stock_warehouse.py index 4c5eff15868..5534dd8f566 100644 --- a/addons/stock/models/stock_warehouse.py +++ b/addons/stock/models/stock_warehouse.py @@ -149,7 +149,7 @@ class Warehouse(models.Model): for warehouse in warehouses: # check if we need to delete and recreate route - depends = [depend for depends in [value['depends'] for value in warehouse._get_routes_values().values()] for depend in depends] + depends = [depend for depends in [value.get('depends', []) for value in warehouse._get_routes_values().values()] for depend in depends] if any(depend in vals for depend in depends): picking_type_vals = warehouse._create_or_update_sequences_and_picking_types() if picking_type_vals: @@ -162,7 +162,7 @@ class Warehouse(models.Model): # _get_global_route_rules_values method under the key named # 'depends'. global_rules = warehouse._get_global_route_rules_values() - depends = [depend for depends in [value['depends'] for value in global_rules.values()] for depend in depends] + depends = [depend for depends in [value.get('depends', []) for value in global_rules.values()] for depend in depends] if any(rule in vals for rule in global_rules) or\ any(depend in vals for depend in depends): warehouse._create_or_update_global_routes_rules() @@ -293,7 +293,7 @@ class Warehouse(models.Model): with the wanted reception, delivery,... steps. """ for rule_field, rule_details in self._get_global_route_rules_values().items(): - values = rule_details['update_values'] + values = rule_details.get('update_values', {}) if self[rule_field]: self[rule_field].write(values) else: @@ -377,11 +377,13 @@ class Warehouse(models.Model): # If the route exists update it if self[route_field]: route = self[route_field] - route.write(route_data['route_update_values']) + if 'route_update_values' in route_data: + route.write(route_data['route_update_values']) route.rule_ids.write({'active': False}) # Create the route else: - route_data['route_create_values'].update(route_data['route_update_values']) + if 'route_update_values' in route_data: + route_data['route_create_values'].update(route_data['route_update_values']) route = self.env['stock.location.route'].create(route_data['route_create_values']) self[route_field] = route # Get rules needed for the route diff --git a/addons/stock/report/report_stockinventory.xml b/addons/stock/report/report_stockinventory.xml index 90abd6b4109..19a988dfe59 100644 --- a/addons/stock/report/report_stockinventory.xml +++ b/addons/stock/report/report_stockinventory.xml @@ -6,6 +6,7 @@ <t t-foreach="docs" t-as="o"> <t t-call="web.external_layout"> <div class="page"> + <br/> <h2>Stock Inventory</h2> <table class="table table-sm"> diff --git a/addons/stock/views/res_partner_views.xml b/addons/stock/views/res_partner_views.xml index 21a9f38da45..d8bb37eb541 100644 --- a/addons/stock/views/res_partner_views.xml +++ b/addons/stock/views/res_partner_views.xml @@ -13,8 +13,8 @@ <xpath expr="//page[@name='sales_purchases']/group" position="inside"> <group name="container_row_stock" groups="base.group_no_one" priority="6"> <group string="Inventory" name="inventory" colspan="2"> - <field name="property_stock_customer" domain="[('usage','=','customer')]"/> - <field name="property_stock_supplier" domain="[('usage','=','supplier')]"/> + <field name="property_stock_customer" /> + <field name="property_stock_supplier" /> </group> </group> </xpath> diff --git a/addons/stock/views/stock_picking_views.xml b/addons/stock/views/stock_picking_views.xml index d73ee9e22d5..7d3eb2d998d 100644 --- a/addons/stock/views/stock_picking_views.xml +++ b/addons/stock/views/stock_picking_views.xml @@ -54,7 +54,7 @@ </group> </group> <group> - <group attrs='{"invisible": [("code", "not in", ["incoming", "outgoing", "internal"])]}' string="Traceability" groups="stock.group_production_lot"> + <group attrs='{"invisible": [("code", "not in", ["incoming", "outgoing", "internal"])]}' string="Traceability" groups="stock.group_production_lot" name="stock_picking_type_lot"> <field name="use_create_lots"/> <field name="use_existing_lots"/> </group> diff --git a/addons/stock/wizard/stock_immediate_transfer.py b/addons/stock/wizard/stock_immediate_transfer.py index 66a652ac795..9b46a91c585 100644 --- a/addons/stock/wizard/stock_immediate_transfer.py +++ b/addons/stock/wizard/stock_immediate_transfer.py @@ -22,7 +22,7 @@ class StockImmediateTransfer(models.TransientModel): picking.action_assign() if picking.state != 'assigned': raise UserError(_("Could not reserve all requested products. Please use the \'Mark as Todo\' button to handle the reservation manually.")) - for move in picking.move_lines: + for move in picking.move_lines.filtered(lambda m: m.state not in ['done', 'cancel']): for move_line in move.move_line_ids: move_line.qty_done = move_line.product_uom_qty if picking._check_backorder(): diff --git a/addons/stock_account/models/stock.py b/addons/stock_account/models/stock.py index f8d6655a21d..82981e657cb 100644 --- a/addons/stock_account/models/stock.py +++ b/addons/stock_account/models/stock.py @@ -155,24 +155,50 @@ class StockMove(models.Model): @api.model def _get_in_base_domain(self, company_id=False): + # Domain: + # - state is done + # - coming from a location without company, or an inventory location within the same company + # - going to a location within the same company domain = [ ('state', '=', 'done'), - ('location_id.company_id', '=', False), - ('location_dest_id.company_id', '=', company_id or self.env.user.company_id.id) + '&', + '|', + ('location_id.company_id', '=', False), + '&', + ('location_id.usage', 'in', ['inventory', 'production']), + ('location_id.company_id', '=', company_id or self.env.user.company_id.id), + ('location_dest_id.company_id', '=', company_id or self.env.user.company_id.id), ] return domain @api.model def _get_all_base_domain(self, company_id=False): + # Domain: + # - state is done + # Then, either 'in' or 'out' moves. + # 'in' moves: + # - coming from a location without company, or an inventory location within the same company + # - going to a location within the same company + # 'out' moves: + # - coming from to a location within the same company + # - going to a location without company, or an inventory location within the same company domain = [ ('state', '=', 'done'), '|', '&', - ('location_id.company_id', '=', False), + '|', + ('location_id.company_id', '=', False), + '&', + ('location_id.usage', 'in', ['inventory', 'production']), + ('location_id.company_id', '=', company_id or self.env.user.company_id.id), ('location_dest_id.company_id', '=', company_id or self.env.user.company_id.id), '&', ('location_id.company_id', '=', company_id or self.env.user.company_id.id), - ('location_dest_id.company_id', '=', False) + '|', + ('location_dest_id.company_id', '=', False), + '&', + ('location_dest_id.usage', '=', 'inventory'), + ('location_dest_id.company_id', '=', company_id or self.env.user.company_id.id), ] return domain @@ -277,7 +303,7 @@ class StockMove(models.Model): if qty_to_take_on_candidates == 0: move.write({ 'value': -tmp_value if not quantity else move.value or -tmp_value, # outgoing move are valued negatively - 'price_unit': -tmp_value / move.product_qty, + 'price_unit': -tmp_value / (move.product_qty or quantity), }) elif qty_to_take_on_candidates > 0: last_fifo_price = new_standard_price or move.product_id.standard_price @@ -533,7 +559,7 @@ class StockMove(models.Model): debit_value = self.company_id.currency_id.round(valuation_amount) # check that all data is correct - if self.company_id.currency_id.is_zero(debit_value): + if self.company_id.currency_id.is_zero(debit_value) and not self.env['ir.config_parameter'].sudo().get_param('stock_account.allow_zero_cost'): raise UserError(_("The cost of %s is currently equal to 0. Change the cost or the configuration of your product to avoid an incorrect valuation.") % (self.product_id.display_name,)) credit_value = debit_value diff --git a/addons/stock_account/tests/test_stockvaluation.py b/addons/stock_account/tests/test_stockvaluation.py index dddde1e0bdc..7b2af95cddd 100644 --- a/addons/stock_account/tests/test_stockvaluation.py +++ b/addons/stock_account/tests/test_stockvaluation.py @@ -3900,3 +3900,48 @@ class TestStockValuation(TransactionCase): self.assertEqual(self.product1.with_context(to_date=Datetime.to_string(date6)).stock_value, 100) self.assertEqual(self.product1.qty_at_date, 10) self.assertEqual(self.product1.stock_value, 100) + + def test_inventory_fifo_1(self): + """ Make an inventory from a location with a company set, and ensure the product has a stock + value. When the product is sold, ensure there is no remaining quantity on the original move + and no stock value. + """ + self.product1.standard_price = 15 + self.product1.product_tmpl_id.cost_method = 'fifo' + inventory_location = self.product1.property_stock_inventory + inventory_location.company_id = self.env.user.company_id.id + + # Start Inventory: 12 units + move1 = self.env['stock.move'].create({ + 'name': 'Adjustment of 12 units', + 'location_id': inventory_location.id, + 'location_dest_id': self.stock_location.id, + 'product_id': self.product1.id, + 'product_uom': self.uom_unit.id, + 'product_uom_qty': 12.0, + }) + move1._action_confirm() + move1._action_assign() + move1.move_line_ids.qty_done = 12.0 + move1._action_done() + + self.assertAlmostEqual(move1.value, 180.0) + self.assertAlmostEqual(move1.remaining_qty, 12.0) + self.assertAlmostEqual(self.product1.stock_value, 180.0) + + # Sell the 12 units + move2 = self.env['stock.move'].create({ + 'name': 'Sell 12 units', + 'location_id': self.stock_location.id, + 'location_dest_id': self.customer_location.id, + 'product_id': self.product1.id, + 'product_uom': self.uom_unit.id, + 'product_uom_qty': 12.0, + }) + move2._action_confirm() + move2._action_assign() + move2.move_line_ids.qty_done = 12.0 + move2._action_done() + + self.assertAlmostEqual(move1.remaining_qty, 0.0) + self.assertAlmostEqual(self.product1.stock_value, 0.0) diff --git a/addons/stock_account/views/product_views.xml b/addons/stock_account/views/product_views.xml index 4919bf85924..b4fd4e46503 100644 --- a/addons/stock_account/views/product_views.xml +++ b/addons/stock_account/views/product_views.xml @@ -67,7 +67,7 @@ <div name="standard_price_div" class="row ml-0"> <field name="standard_price" attrs="{'readonly':[('valuation','=','real_time'), ('cost_method', '!=', 'fifo')]}" - widget='monetary' options="{'currency_field': 'currency_id', 'field_digits': True}" class="float-left"/> + widget='monetary' options="{'currency_field': 'cost_currency_id', 'field_digits': True}" class="float-left"/> <div name="update_cost_price"> <button string="Update Cost" type="action" name="%(action_view_change_standard_price)d" diff --git a/addons/survey/controllers/main.py b/addons/survey/controllers/main.py index 320b0659ef9..f576e971061 100644 --- a/addons/survey/controllers/main.py +++ b/addons/survey/controllers/main.py @@ -72,7 +72,7 @@ class Survey(http.Controller): if (survey_sudo.is_closed or not survey_sudo.active) and (not answer_sudo or not answer_sudo.test_entry): return 'survey_closed' - if not survey_sudo.page_ids and survey_sudo.questions_layout != 'page_per_question': + if (not survey_sudo.page_ids and survey_sudo.questions_layout == 'page_per_section') or not survey_sudo.question_ids: return 'survey_void' if answer_sudo and answer_sudo.state == 'done': diff --git a/addons/survey/data/survey_demo_certification.xml b/addons/survey/data/survey_demo_certification.xml index fde324b6ffb..b2696793449 100644 --- a/addons/survey/data/survey_demo_certification.xml +++ b/addons/survey/data/survey_demo_certification.xml @@ -115,7 +115,7 @@ <field name="sequence">3</field> <field name="value">Width</field> <field name="is_correct" eval="True" /> - <field name="answer_score">-1.0</field> + <field name="answer_score">1.0</field> </record> <record model="survey.label" id="vendor_certification_page_1_question_3_choice_4"> <field name="question_id" ref="vendor_certification_page_1_question_3"/> diff --git a/addons/survey/models/survey_survey.py b/addons/survey/models/survey_survey.py index d6c46ceba07..c2452b55524 100644 --- a/addons/survey/models/survey_survey.py +++ b/addons/survey/models/survey_survey.py @@ -279,7 +279,9 @@ class Survey(models.Model): """ survey = user_input.survey_id - if survey.questions_layout == 'page_per_question' and survey.questions_selection == 'random': + if survey.questions_layout == 'one_page': + return (None, False) + elif survey.questions_layout == 'page_per_question' and survey.questions_selection == 'random': pages_or_questions = list(enumerate( user_input.question_ids )) @@ -452,7 +454,7 @@ class Survey(models.Model): def action_send_survey(self): """ Open a window to compose an email, pre-filled with the survey message """ # Ensure that this survey has at least one page with at least one question. - if (not self.page_ids and self.questions_layout != 'page_per_question') or not self.question_ids: + if (not self.page_ids and self.questions_layout == 'page_per_section') or not self.question_ids: raise UserError(_('You cannot send an invitation for a survey that has no questions.')) if self.stage_id.closed: diff --git a/addons/survey/static/src/js/survey.js b/addons/survey/static/src/js/survey.js index a2b5ab66a6f..d232c3073f7 100644 --- a/addons/survey/static/src/js/survey.js +++ b/addons/survey/static/src/js/survey.js @@ -98,7 +98,10 @@ if(!the_form.length) { } // checkboxes and radios - the_form.find("input[name^=" + key + "][type!='text']").each(function(){ + the_form.find("input[name^='" + key + "_'][type='checkbox']").each(function(){ + $(this).val(value); + }); + the_form.find("input[name=" + key + "][type!='text']").each(function(){ $(this).val(value); }); }); diff --git a/addons/web/static/src/js/chrome/systray_menu.js b/addons/web/static/src/js/chrome/systray_menu.js index fd214c0b650..cb292a9b90f 100644 --- a/addons/web/static/src/js/chrome/systray_menu.js +++ b/addons/web/static/src/js/chrome/systray_menu.js @@ -1,6 +1,7 @@ odoo.define('web.SystrayMenu', function (require) { "use strict"; +var dom = require('web.dom'); var Widget = require('web.Widget'); /** @@ -18,35 +19,35 @@ var SystrayMenu = Widget.extend({ this.widgets = []; }, /** + * Instanciate the items and add them into a temporary fragmenet * @override - * @returns {Promise} */ - start: function () { + willStart: function () { var self = this; - self._super.apply(this, arguments); - self._loadItems(); - - return new Promise(function (resolve, reject) { - Promise.all(self.items).then(resolve).guardedCatch(resolve); + var proms = []; + SystrayMenu.Items = _.sortBy(SystrayMenu.Items, function (item) { + return !_.isUndefined(item.prototype.sequence) ? item.prototype.sequence : 50; }); - }, - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- + SystrayMenu.Items.forEach(function (WidgetClass) { + var cur_systray_item = new WidgetClass(self); + self.widgets.push(cur_systray_item); + proms.push(cur_systray_item.appendTo($('<div>'))); + }); + return this._super.apply(this, arguments).then(function () { + return Promise.all(proms); + }); + }, /** - * Instantiate items, using the classes located in SystrayMenu.items. + * Add the instanciated items, using the object located in this.wisgets */ - _loadItems: function () { + start: function () { var self = this; - SystrayMenu.Items = _.sortBy(SystrayMenu.Items, function (item) { - return !_.isUndefined(item.prototype.sequence) ? item.prototype.sequence : 50; - }); - _.each(SystrayMenu.Items, function (WidgetClass) { - var cur_systray_item = new WidgetClass(self); - self.widgets.push(cur_systray_item); - self.items.push(cur_systray_item.prependTo(self.$el)); + return this._super.apply(this, arguments).then(function () { + self.widgets.forEach(function (widget) { + dom.prepend(self.$el, widget.$el); + }); }); }, }); diff --git a/addons/web/static/src/js/core/domain.js b/addons/web/static/src/js/core/domain.js index c7533aabae0..9c74dcb953e 100644 --- a/addons/web/static/src/js/core/domain.js +++ b/addons/web/static/src/js/core/domain.js @@ -263,8 +263,8 @@ var Domain = collections.Tree.extend({ }); break; case 'previous_year': - leftBoundaryParams.years = leftBoundaryParams.years ? leftBoundaryParams.years-- : -1; - rightBoundaryParams.years = rightBoundaryParams.years ? rightBoundaryParams.years-- : -1; + leftBoundaryParams.years = leftBoundaryParams.years ? leftBoundaryParams.years - 1 : -1; + rightBoundaryParams.years = rightBoundaryParams.years ? rightBoundaryParams.years - 1 : -1; break; } diff --git a/addons/web/static/src/js/views/basic/basic_renderer.js b/addons/web/static/src/js/views/basic/basic_renderer.js index f741e11b789..4c03d9b77a1 100644 --- a/addons/web/static/src/js/views/basic/basic_renderer.js +++ b/addons/web/static/src/js/views/basic/basic_renderer.js @@ -731,8 +731,12 @@ var BasicRenderer = AbstractRenderer.extend({ var defs = []; this.defs = defs; // Potentially filled by widget rerendering _.each(this.allModifiersData, function (modifiersData) { - modifiersData.evaluatedModifiers[record.id] = record.evalModifiers(modifiersData.modifiers); - self._applyModifiers(modifiersData, record); + // `allModifiersData` might contain modifiers registered for other + // records than the given record (e.g. <groupby> in list) + if (record.id in modifiersData.evaluatedModifiers) { + modifiersData.evaluatedModifiers[record.id] = record.evalModifiers(modifiersData.modifiers); + self._applyModifiers(modifiersData, record); + } }); delete this.defs; diff --git a/addons/web/static/src/js/views/calendar/calendar_controller.js b/addons/web/static/src/js/views/calendar/calendar_controller.js index ec85b567cf7..dda3978635b 100644 --- a/addons/web/static/src/js/views/calendar/calendar_controller.js +++ b/addons/web/static/src/js/views/calendar/calendar_controller.js @@ -272,6 +272,7 @@ var CalendarController = AbstractController.extend({ res_model: this.modelName, context: context, title: title, + view_id: this.formViewId || false, disable_multiple_selection: true, on_saved: function () { if (event.data.on_save) { diff --git a/addons/web/static/src/js/views/list/list_editable_renderer.js b/addons/web/static/src/js/views/list/list_editable_renderer.js index 9f37fcecbea..818739c6f48 100644 --- a/addons/web/static/src/js/views/list/list_editable_renderer.js +++ b/addons/web/static/src/js/views/list/list_editable_renderer.js @@ -435,6 +435,18 @@ ListRenderer.include({ }); } }, + /** + * @override + */ + updateState: function (state, params) { + if (params.noRender) { + // the state changed, but we won't do a re-rendering right now, so + // remove computed modifiers data (as they are obsolete) to force + // them to be recomputed at next (sub-)rendering + this.allModifiersData = []; + } + return this._super.apply(this, arguments); + }, //-------------------------------------------------------------------------- // Private @@ -586,7 +598,7 @@ ListRenderer.include({ this.commitChanges(curRecordId).then(function () { var $prevRow = self._getRow(self.allRecordIds[prevRecordIndex]); var prevRowIndex = $prevRow.prop('rowIndex') - 1; - self._selectCell(prevRowIndex, self.columns.length - 1); + self._selectCell(prevRowIndex, self.columns.length - 1, {inc: -1}); }); }, /** @@ -955,6 +967,8 @@ ListRenderer.include({ * @param {boolean} [options.force=false] if true, force selecting the cell * even if seems to be already the selected one (useful after a re- * rendering, to reset the focus on the correct field) + * @param {integer} [options.inc=1] the increment to use when searching for + * the "next" possible cell (if the cell to select can't be selected) * @return {Promise} fails if no cell could be selected */ _selectCell: function (rowIndex, fieldIndex, options) { @@ -979,7 +993,7 @@ ListRenderer.include({ var oldFieldIndex = self.currentFieldIndex; self.currentFieldIndex = fieldIndex; fieldIndex = self._activateFieldWidget(record, fieldIndex, { - inc: 1, + inc: options.inc || 1, wrap: wrap, event: options && options.event, }); @@ -1183,7 +1197,7 @@ ListRenderer.include({ switch (ev.data.direction) { case 'previous': if (this.currentFieldIndex > 0) { - this._selectCell(this.currentRow, this.currentFieldIndex - 1, {wrap: false}) + this._selectCell(this.currentRow, this.currentFieldIndex - 1, {inc: -1, wrap: false}) .guardedCatch(this._moveToPreviousLine.bind(this)); } else { this._moveToPreviousLine(); diff --git a/addons/web/static/src/scss/layout_background.scss b/addons/web/static/src/scss/layout_background.scss index 9b137fde8c1..caeb1f0c2bf 100644 --- a/addons/web/static/src/scss/layout_background.scss +++ b/addons/web/static/src/scss/layout_background.scss @@ -82,3 +82,10 @@ } } } + +/* special case for displaying report in iframe */ +.o_in_iframe { + .o_background_header { + min-width: 0; + } +} diff --git a/addons/web/static/src/scss/list_view.scss b/addons/web/static/src/scss/list_view.scss index 07da78731f3..86b989dbd10 100644 --- a/addons/web/static/src/scss/list_view.scss +++ b/addons/web/static/src/scss/list_view.scss @@ -76,7 +76,7 @@ } } - .o_list_record_selector { + .o_list_record_selector, .o_list_record_remove, .o_handle_cell { width: 1px; // to prevent the column to expand } diff --git a/addons/web/static/tests/chrome/systray_tests.js b/addons/web/static/tests/chrome/systray_tests.js new file mode 100644 index 00000000000..c5238ceff04 --- /dev/null +++ b/addons/web/static/tests/chrome/systray_tests.js @@ -0,0 +1,43 @@ +odoo.define('web.systray_tests', function (require) { + "use strict"; + + var testUtils = require('web.test_utils'); + var SystrayMenu = require('web.SystrayMenu'); + var Widget = require('web.Widget'); + + QUnit.test('Adding async components to the registry respects the sequence', async function (assert) { + assert.expect(2); + var parent = testUtils.createParent({ + }); + var prom = testUtils.makeTestPromise(); + + var synchronousFirstWidget = Widget.extend({ + sequence: 3, // bigger sequence means more to the left + start: function () { + this.$el.addClass('first'); + } + }); + var asynchronousSecondWidget = Widget.extend({ + sequence: 1, // smaller sequence means more to the right + willStart: function () { + return prom; + }, + start: function () { + this.$el.addClass('second'); + } + }); + + SystrayMenu.Items = [synchronousFirstWidget, asynchronousSecondWidget]; + var menu = new SystrayMenu(parent); + + menu.appendTo($('#qunit-fixture')); + await testUtils.nextTick(); + prom.resolve(); + await testUtils.nextTick(); + + assert.hasClass(menu.$('div:eq(0)'), 'first'); + assert.hasClass(menu.$('div:eq(1)'), 'second'); + + parent.destroy(); + }) +}); diff --git a/addons/web/static/tests/helpers/test_utils_create.js b/addons/web/static/tests/helpers/test_utils_create.js index 097d46e93f7..f1c67284707 100644 --- a/addons/web/static/tests/helpers/test_utils_create.js +++ b/addons/web/static/tests/helpers/test_utils_create.js @@ -392,7 +392,6 @@ function createParent(params) { return { createActionManager: createActionManager, - createView: createView, createCalendarView: createCalendarView, createControlPanel: createControlPanel, createDebugManager: createDebugManager, diff --git a/addons/web/static/tests/views/calendar_tests.js b/addons/web/static/tests/views/calendar_tests.js index 9f637c4fdc5..01f431589fc 100644 --- a/addons/web/static/tests/views/calendar_tests.js +++ b/addons/web/static/tests/views/calendar_tests.js @@ -2371,6 +2371,42 @@ QUnit.module('Views', { calendar.destroy(); }); + QUnit.test('form_view_id attribute works with popup (for creating events)', async function (assert) { + assert.expect(1); + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: '<calendar class="o_calendar_test" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'mode="month" '+ + 'event_open_popup="true" ' + + 'quick_add="false" ' + + 'form_view_id="1">'+ + '<field name="name"/>'+ + '</calendar>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + mockRPC: function (route, args) { + if (args.method === "load_views") { + assert.strictEqual(args.kwargs.views[0][0], 1, + "should load view with id 1"); + } + return this._super(route, args); + }, + }); + + var $cell = calendar.$('.fc-day-grid .fc-row:eq(2) .fc-day:eq(2)'); + testUtils.dom.triggerMouseEvent($cell, "mousedown"); + testUtils.dom.triggerMouseEvent($cell, "mouseup"); + await testUtils.nextTick(); + calendar.destroy(); + }); + QUnit.test('calendar fallback to form view id in action if necessary', async function (assert) { assert.expect(1); diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js index 5e5a0c7b7a1..7aff294fb97 100644 --- a/addons/web/static/tests/views/list_tests.js +++ b/addons/web/static/tests/views/list_tests.js @@ -1957,6 +1957,37 @@ QUnit.module('Views', { list.destroy(); }); + QUnit.test('editable list view with groupby node and modifiers', async function (assert) { + assert.expect(3); + + var list = await createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree expand="1" editable="bottom">' + + '<field name="foo"/>' + + '<groupby name="currency_id">' + + '<field name="position"/>' + + '<button string="Button 1" type="object" name="button_method" attrs=\'{"invisible": [("position", "=", "after")]}\'/>' + + '</groupby>' + + '</tree>', + groupBy: ['currency_id'], + }); + + assert.doesNotHaveClass(list.$('.o_data_row:first'), 'o_selected_row', + "first row should be in readonly mode"); + + await testUtils.dom.click(list.$('.o_data_row:first .o_data_cell')); + assert.hasClass(list.$('.o_data_row:first'), 'o_selected_row', + "the row should be in edit mode"); + + await testUtils.fields.triggerKeydown($(document.activeElement), 'escape'); + assert.doesNotHaveClass(list.$('.o_data_row:first'), 'o_selected_row', + "the row should be back in readonly mode"); + + list.destroy(); + }); + QUnit.test('groupby node with edit button', async function (assert) { assert.expect(1); @@ -3212,6 +3243,90 @@ QUnit.module('Views', { list.destroy(); }); + QUnit.test('pressing SHIFT-TAB in editable list with a readonly field [REQUIRE FOCUS]', async function (assert) { + assert.expect(4); + + var list = await createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree editable="bottom">' + + '<field name="foo"/>' + + '<field name="int_field" readonly="1"/>' + + '<field name="qux"/>' + + '</tree>', + }); + + // start on 'qux', line 3 + await testUtils.dom.click(list.$('.o_data_row:nth(2) .o_data_cell:nth(2)')); + assert.hasClass(list.$('.o_data_row:nth(2)'), 'o_selected_row'); + assert.strictEqual(document.activeElement, list.$('.o_data_row:nth(2) .o_data_cell input[name=qux]')[0]); + + // Press 'shift-Tab' -> should go to first cell (same line) + $(document.activeElement).trigger({type: 'keydown', which: $.ui.keyCode.TAB, shiftKey: true}); + await testUtils.nextTick(); + assert.hasClass(list.$('.o_data_row:nth(2)'), 'o_selected_row'); + assert.strictEqual(document.activeElement, list.$('.o_data_row:nth(2) .o_data_cell input[name=foo]')[0]); + + list.destroy(); + }); + + QUnit.test('pressing SHIFT-TAB in editable list with a readonly field in first column [REQUIRE FOCUS]', async function (assert) { + assert.expect(4); + + var list = await createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree editable="bottom">' + + '<field name="int_field" readonly="1"/>' + + '<field name="foo"/>' + + '<field name="qux"/>' + + '</tree>', + }); + + // start on 'foo', line 3 + await testUtils.dom.click(list.$('.o_data_row:nth(2) .o_data_cell:nth(1)')); + assert.hasClass(list.$('.o_data_row:nth(2)'), 'o_selected_row'); + assert.strictEqual(document.activeElement, list.$('.o_data_row:nth(2) .o_data_cell input[name=foo]')[0]); + + // Press 'shift-Tab' -> should go to previous line (last cell) + $(document.activeElement).trigger({type: 'keydown', which: $.ui.keyCode.TAB, shiftKey: true}); + await testUtils.nextTick(); + assert.hasClass(list.$('.o_data_row:nth(1)'), 'o_selected_row'); + assert.strictEqual(document.activeElement, list.$('.o_data_row:nth(1) .o_data_cell input[name=qux]')[0]); + + list.destroy(); + }); + + QUnit.test('pressing SHIFT-TAB in editable list with a readonly field in last column [REQUIRE FOCUS]', async function (assert) { + assert.expect(4); + + var list = await createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree editable="bottom">' + + '<field name="int_field"/>' + + '<field name="foo"/>' + + '<field name="qux" readonly="1"/>' + + '</tree>', + }); + + // start on 'int_field', line 3 + await testUtils.dom.click(list.$('.o_data_row:nth(2) .o_data_cell:first')); + assert.hasClass(list.$('.o_data_row:nth(2)'), 'o_selected_row'); + assert.strictEqual(document.activeElement, list.$('.o_data_row:nth(2) .o_data_cell input[name=int_field]')[0]); + + // Press 'shift-Tab' -> should go to previous line ('foo' field) + $(document.activeElement).trigger({type: 'keydown', which: $.ui.keyCode.TAB, shiftKey: true}); + await testUtils.nextTick(); + assert.hasClass(list.$('.o_data_row:nth(1)'), 'o_selected_row'); + assert.strictEqual(document.activeElement, list.$('.o_data_row:nth(1) .o_data_cell input[name=foo]')[0]); + + list.destroy(); + }); + QUnit.test('skip invisible fields when navigating list view with TAB', async function (assert) { assert.expect(2); @@ -4460,6 +4575,47 @@ QUnit.module('Views', { list.destroy(); }); + QUnit.test('create record on list with modifiers depending on id', async function (assert) { + assert.expect(8); + + var list = await createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree editable="top">' + + '<field name="id" invisible="1"/>' + + '<field name="foo" attrs="{\'readonly\': [[\'id\',\'!=\',False]]}"/>' + + '<field name="int_field" attrs="{\'invisible\': [[\'id\',\'!=\',False]]}"/>' + + '</tree>', + }); + + // add a new record + await testUtils.dom.click(list.$buttons.find('.o_list_button_add')); + + // modifiers should be evaluted to false + assert.containsOnce(list, '.o_selected_row'); + assert.doesNotHaveClass(list.$('.o_selected_row .o_data_cell:first'), 'o_readonly_modifier'); + assert.doesNotHaveClass(list.$('.o_selected_row .o_data_cell:nth(1)'), 'o_invisible_modifier'); + + // set a value and save + await testUtils.fields.editInput(list.$('.o_selected_row input[name=foo]'), 'some value'); + await testUtils.dom.click(list.$buttons.find('.o_list_button_save')); + + // modifiers should be evaluted to true + assert.hasClass(list.$('.o_data_row:first .o_data_cell:first'), 'o_readonly_modifier'); + assert.hasClass(list.$('.o_data_row:first .o_data_cell:nth(1)'), 'o_invisible_modifier'); + + // edit again the just created record + await testUtils.dom.click(list.$('.o_data_row:first .o_data_cell:first')); + + // modifiers should be evaluted to true + assert.containsOnce(list, '.o_selected_row'); + assert.hasClass(list.$('.o_selected_row .o_data_cell:first'), 'o_readonly_modifier'); + assert.hasClass(list.$('.o_selected_row .o_data_cell:nth(1)'), 'o_invisible_modifier'); + + list.destroy(); + }); + QUnit.test('grouped list with async widget', async function (assert) { assert.expect(4); diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml index 3759917ac09..5e5a19a85ba 100644 --- a/addons/web/views/webclient_templates.xml +++ b/addons/web/views/webclient_templates.xml @@ -586,6 +586,7 @@ <script type="text/javascript" src="/web/static/tests/chrome/pager_tests.js"></script> <script type="text/javascript" src="/web/static/tests/chrome/keyboard_navigation_mixin_tests.js"></script> <script type="text/javascript" src="/web/static/tests/chrome/user_menu_tests.js"></script> + <script type="text/javascript" src="/web/static/tests/chrome/systray_tests.js"></script> <script type="text/javascript" src="/web/static/tests/widgets/data_export_tests.js"/> <script type="text/javascript" src="/web/static/tests/widgets/domain_selector_tests.js"/> diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/keyboard.js b/addons/web_editor/static/src/js/wysiwyg/plugin/keyboard.js index 0cb451c100d..3ab6a5f0317 100644 --- a/addons/web_editor/static/src/js/wysiwyg/plugin/keyboard.js +++ b/addons/web_editor/static/src/js/wysiwyg/plugin/keyboard.js @@ -969,13 +969,13 @@ var KeyboardPlugin = AbstractPlugin.extend({ var pointA = this.context.invoke('HelperPlugin.makePoint', startNode, 0); pointA = dom.nextPointUntil(pointA, function (point) { return self.context.invoke('HelperPlugin.isVisibleText', point.node); - }); + }) || pointA; var endNode = $contents.length ? $contents[$contents.length - 1] : unbreakable; var endOffset = $contents.length ? dom.nodeLength($contents[$contents.length - 1]) : 1; var pointB = this.context.invoke('HelperPlugin.makePoint', endNode, endOffset); pointB = dom.prevPointUntil(pointB, function (point) { return self.context.invoke('HelperPlugin.isVisibleText', point.node); - }); + }) || pointB; range.sc = pointA.node; range.so = pointA.offset; range.ec = pointB.node; diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/media.js b/addons/web_editor/static/src/js/wysiwyg/widgets/media.js index 44793603027..ddd987f4ed2 100644 --- a/addons/web_editor/static/src/js/wysiwyg/widgets/media.js +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/media.js @@ -909,7 +909,7 @@ var VideoWidget = MediaWidget.extend({ options = options || {}; // Video url patterns(youtube, instagram, vimeo, dailymotion, youku, ...) - var ytRegExp = /^(?:(?:https?:)?\/\/)?(?:www\.)?(?:youtu\.be\/|youtube\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})(?:\S+)?$/; + var ytRegExp = /^(?:(?:https?:)?\/\/)?(?:www\.)?(?:youtu\.be\/|youtube(-nocookie)?\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((?:\w|-){11})(?:\S+)?$/; var ytMatch = url.match(ytRegExp); var insRegExp = /(.*)instagram.com\/p\/(.[a-zA-Z0-9]*)/; @@ -936,8 +936,8 @@ var VideoWidget = MediaWidget.extend({ var autoplay = options.autoplay ? '?autoplay=1' : '?autoplay=0'; - if (ytMatch && ytMatch[1].length === 11) { - $video.attr('src', '//www.youtube.com/embed/' + ytMatch[1] + autoplay); + if (ytMatch && ytMatch[2].length === 11) { + $video.attr('src', '//www.youtube' + (ytMatch[1] || '') + '.com/embed/' + ytMatch[2] + autoplay); } else if (insMatch && insMatch[2].length) { $video.attr('src', '//www.instagram.com/p/' + insMatch[2] + '/embed/'); videoType = 'ins'; diff --git a/addons/web_editor/static/src/scss/web_editor.common.scss b/addons/web_editor/static/src/scss/web_editor.common.scss index 76f167e0a7d..de51b9d8a37 100644 --- a/addons/web_editor/static/src/scss/web_editor.common.scss +++ b/addons/web_editor/static/src/scss/web_editor.common.scss @@ -272,3 +272,113 @@ a.o_underline { text-decoration: underline; } } + +// ACE EDITOR +.o_ace_view_editor { + background: $o-we-ace-color; + color: white; + display: flex; + flex-flow: column nowrap; + opacity: 0.97; + + .o_ace_view_editor_title { + flex: 0 0 auto; + display: flex; + align-items: center; + padding: $grid-gutter-width/4; + + >.o_ace_type_switcher>button::after { + @include o-caret-down; + margin-left: 4px; + } + + >* { + flex: 0 0 auto; + margin: 0 $grid-gutter-width/4; + + &.o_include_option { + display: flex; + align-items: center; + font-size: 11px; + + >.custom-control { + margin-right: $grid-gutter-width/4; + } + } + + &.o_res_list { + flex: 1 1 auto; + min-width: 60px; + } + } + } + + #ace-view-id { + flex: 0 0 auto; + padding: $grid-gutter-width/4 $grid-gutter-width/2; + background-color: lighten($o-we-ace-color, 10%); + } + + #ace-view-editor { + @mixin ace-line-error-mixin { + content: ""; + z-index: 1000; + display: block; + background-color: theme-color('danger'); + opacity: 0.5; + pointer-events: none; + } + + height: 70%; // in case flex is not supported + flex: 1 1 auto; + + .ace_gutter { + display: block !important; // display even with aria-hidden + cursor: ew-resize; + + .ace_gutter-cell.o_error { + position: relative; + + &::after { + @include o-position-absolute(-100%, 0, -100%, 0); + @include ace-line-error-mixin; + } + } + } + + .ace_resize_bar { + @include o-position-absolute($right: 0); + width: 25px; + height: 100%; + cursor: ew-resize; + } + + .ace_scroller.o_error::after { + @include o-position-absolute(0, auto, 0, 0); + width: 3px; + @include ace-line-error-mixin; + } + } +} + +.o_ace_select2_dropdown { + width: auto !important; + padding-top: 4px; + font-family: monospace !important; + + >.select2-results { + max-height: none; + max-height: 70vh; + + .select2-result-label { + padding-top: 1px; + padding-bottom: 2px; + + >.o_ace_select2_result { + padding: 0; + font-size: 12px; + white-space: nowrap; + } + } + } +} diff --git a/addons/website/models/ir_qweb.py b/addons/website/models/ir_qweb.py index 0a36e8fa8ee..ea33aa114b2 100644 --- a/addons/website/models/ir_qweb.py +++ b/addons/website/models/ir_qweb.py @@ -4,6 +4,7 @@ import re from collections import OrderedDict from odoo import models +from odoo.addons.http_routing.models.ir_http import url_for from odoo.http import request from odoo.addons.base.models.assetsbundle import AssetsBundle from odoo.tools import html_escape as escape @@ -50,10 +51,16 @@ class QWeb(models.AbstractModel): if not website and options.get('website_id'): website = self.env['website'].browse(options['website_id']) - if not website or not website.cdn_activated: + if not website: return atts name = self.URL_ATTRS.get(tagName) + if request and name and name in atts: + atts[name] = url_for(atts[name]) + + if not website.cdn_activated: + return atts + if name and name in atts: atts = OrderedDict(atts) atts[name] = website.get_cdn_url(atts[name]) diff --git a/addons/website/static/src/js/backend/dashboard.js b/addons/website/static/src/js/backend/dashboard.js index d177c8e9e42..83c16278b6d 100644 --- a/addons/website/static/src/js/backend/dashboard.js +++ b/addons/website/static/src/js/backend/dashboard.js @@ -471,7 +471,8 @@ var Dashboard = AbstractAction.extend({ container: $analytics_chart_2[0], options: { title: 'All', - width: '100%' + width: '100%', + tooltip: {isHtml: true}, } } }); diff --git a/addons/website/static/src/scss/website.editor.ui.scss b/addons/website/static/src/scss/website.editor.ui.scss index 422ac472f82..403b7929a92 100644 --- a/addons/website/static/src/scss/website.editor.ui.scss +++ b/addons/website/static/src/scss/website.editor.ui.scss @@ -51,113 +51,3 @@ } } } - -// ACE EDITOR -.o_ace_view_editor { - background: $o-we-ace-color; - color: white; - display: flex; - flex-flow: column nowrap; - opacity: 0.97; - - .o_ace_view_editor_title { - flex: 0 0 auto; - display: flex; - align-items: center; - padding: $grid-gutter-width/4; - - >.o_ace_type_switcher>button::after { - @include o-caret-down; - margin-left: 4px; - } - - >* { - flex: 0 0 auto; - margin: 0 $grid-gutter-width/4; - - &.o_include_option { - display: flex; - align-items: center; - font-size: 11px; - - >.custom-control { - margin-right: $grid-gutter-width/4; - } - } - - &.o_res_list { - flex: 1 1 auto; - min-width: 60px; - } - } - } - - #ace-view-id { - flex: 0 0 auto; - padding: $grid-gutter-width/4 $grid-gutter-width/2; - background-color: lighten($o-we-ace-color, 10%); - } - - #ace-view-editor { - @mixin ace-line-error-mixin { - content: ""; - z-index: 1000; - display: block; - background-color: theme-color('danger'); - opacity: 0.5; - pointer-events: none; - } - - height: 70%; // in case flex is not supported - flex: 1 1 auto; - - .ace_gutter { - display: block !important; // display even with aria-hidden - cursor: ew-resize; - - .ace_gutter-cell.o_error { - position: relative; - - &::after { - @include o-position-absolute(-100%, 0, -100%, 0); - @include ace-line-error-mixin; - } - } - } - - .ace_resize_bar { - @include o-position-absolute($right: 0); - width: 25px; - height: 100%; - cursor: ew-resize; - } - - .ace_scroller.o_error::after { - @include o-position-absolute(0, auto, 0, 0); - width: 3px; - @include ace-line-error-mixin; - } - } -} - -.o_ace_select2_dropdown { - width: auto !important; - padding-top: 4px; - font-family: monospace !important; - - >.select2-results { - max-height: none; - max-height: 70vh; - - .select2-result-label { - padding-top: 1px; - padding-bottom: 2px; - - >.o_ace_select2_result { - padding: 0; - font-size: 12px; - white-space: nowrap; - } - } - } -} diff --git a/addons/website/tests/test_qweb.py b/addons/website/tests/test_qweb.py index 67e22117cde..20dd0ce6f9d 100644 --- a/addons/website/tests/test_qweb.py +++ b/addons/website/tests/test_qweb.py @@ -2,7 +2,9 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. import re +import werkzeug +import odoo from odoo import tools from odoo.modules.module import get_module_resource from odoo.tests.common import TransactionCase @@ -65,3 +67,123 @@ class TestQweb(TransactionCase): "filename": "Marc%20Demo", "alt": "Marc Demo", }).encode('utf8')) + +class MockObject(object): + _log_call = [] + def __init__(self, *args, **kwargs): + self.__dict__ = kwargs + def __call__(self, *args, **kwargs): + self._log_call.append((args, kwargs)) + return self + def __getitem__(self, index): + return self + +def werkzeugRaiseNotFound(*args, **kwargs): + raise werkzeug.exceptions.NotFound() + +class MockRequest(object): + """ Class with context manager mocking odoo.http.request for tests """ + def __init__(self, env, website=None, context=None, multilang=True, routing=True): + app = MockObject(routing={ + 'type': 'http', + 'website': True, + 'multilang': multilang, + }) + app.get_db_router = app.bind = app.match = app + if not routing: + app.match = werkzeugRaiseNotFound + self.request = MockObject( + env=env, context=context or {}, db=None, debug=False, + website=website, httprequest=MockObject( + path='/hello/', + app=app + ) + ) + odoo.http._request_stack.push(self.request) + def __enter__(self): + return self.request + def __exit__(self, exc_type, exc_value, traceback): + odoo.http._request_stack.pop() + +class TestQwebProcessAtt(TransactionCase): + def setUp(self): + super(TestQwebProcessAtt, self).setUp() + self.website = self.env['website'].browse(1) + self.website.language_ids = self.env.ref('base.lang_en') + self.env.ref('base.lang_fr') + self.website.default_lang_id = self.env.ref('base.lang_en') + self.website.cdn_activated = True + self.website.cdn_url = "http://test.cdn" + self.website.cdn_filters = "\n".join(["^(/[a-z]{2}_[A-Z]{2})?/a$", "^/b$"]) + + def _test_att(self, url, expect, tag='a', attribute='href'): + self.assertEqual( + self.env['ir.qweb']._post_processing_att(tag, {attribute: url}, {}), + expect + ) + + def test_process_att_no_request(self): + # no request so no URL rewriting + self._test_att('/', {'href': '/'}) + self._test_att('/en_US/', {'href': '/en_US/'}) + self._test_att('/fr_FR/', {'href': '/fr_FR/'}) + # no URL rewritting for CDN + self._test_att('/a', {'href': '/a'}) + + def test_process_att_no_website(self): + with MockRequest(self.env) as request: + # no website so URL rewriting + self._test_att('/', {'href': '/'}) + self._test_att('/en_US/', {'href': '/en_US/'}) + self._test_att('/fr_FR/', {'href': '/fr_FR/'}) + # no URL rewritting for CDN + self._test_att('/a', {'href': '/a'}) + + def test_process_att_monolang_route(self): + with MockRequest(self.env, website=self.website, multilang=False) as request: + # lang not changed in URL but CDN enabled + self._test_att('/a', {'href': 'http://test.cdn/a'}) + self._test_att('/en_US/a', {'href': 'http://test.cdn/en_US/a'}) + self._test_att('/b', {'href': 'http://test.cdn/b'}) + self._test_att('/en_US/b', {'href': '/en_US/b'}) + + def test_process_att_no_request_lang(self): + with MockRequest(self.env, self.website) as request: + self._test_att('/', {'href': '/'}) + self._test_att('/en_US/', {'href': '/'}) + self._test_att('/fr_FR/', {'href': '/fr_FR/'}) + + def test_process_att_with_request_lang(self): + with MockRequest(self.env, self.website, context={'lang': 'fr_FR'}) as request: + self._test_att('/', {'href': '/fr_FR/'}) + self._test_att('/en_US/', {'href': '/'}) + self._test_att('/fr_FR/', {'href': '/fr_FR/'}) + + def test_process_att_matching_cdn_and_lang(self): + with MockRequest(self.env, self.website) as request: + # lang prefix is added before CDN + self._test_att('/a', {'href': 'http://test.cdn/a'}) + self._test_att('/en_US/a', {'href': 'http://test.cdn/a'}) + self._test_att('/fr_FR/a', {'href': 'http://test.cdn/fr_FR/a'}) + self._test_att('/b', {'href': 'http://test.cdn/b'}) + self._test_att('/en_US/b', {'href': 'http://test.cdn/b'}) + self._test_att('/fr_FR/b', {'href': '/fr_FR/b'}) + + def test_process_att_no_route(self): + with MockRequest(self.env, self.website, context={'lang': 'fr_FR'}, routing=False) as request: + # default on multilang=True if route is not /{module}/static/ + self._test_att('/web/static/hi', {'href': '/web/static/hi'}) + self._test_att('/my-page', {'href': '/fr_FR/my-page'}) + + def test_process_att_url_crap(self): + with MockRequest(self.env, self.website) as request: + # #{fragment} is stripped from URL when testing route + self._test_att('/x#y?z', {'href': '/x#y?z'}) + self.assertEqual( + request.httprequest.app._log_call[-1], + (('/x',), {'method': 'POST', 'query_args': None}) + ) + self._test_att('/x?y#z', {'href': '/x?y#z'}) + self.assertEqual( + request.httprequest.app._log_call[-1], + (('/x',), {'method': 'POST', 'query_args': 'y'}) + ) diff --git a/addons/website_crm_partner_assign/models/crm_lead.py b/addons/website_crm_partner_assign/models/crm_lead.py index 4f1cb4cfe81..a4fad8e8732 100644 --- a/addons/website_crm_partner_assign/models/crm_lead.py +++ b/addons/website_crm_partner_assign/models/crm_lead.py @@ -4,7 +4,7 @@ import random from odoo import api, fields, models, _ -from odoo.exceptions import AccessDenied +from odoo.exceptions import AccessDenied, AccessError class CrmLead(models.Model): @@ -270,3 +270,35 @@ class CrmLead(models.Model): return { 'id': lead.id } + + # + # DO NOT FORWARD PORT IN MASTER + # instead, crm.lead should implement portal.mixin + # + @api.multi + def get_access_action(self, access_uid=None): + """ Instead of the classic form view, redirect to the online document for + portal users or if force_website=True in the context. """ + self.ensure_one() + + user, record = self.env.user, self + if access_uid: + try: + record.check_access_rights('read') + record.check_access_rule("read") + except AccessError: + return super(CrmLead, self).get_access_action(access_uid) + user = self.env['res.users'].sudo().browse(access_uid) + record = self.sudo(user) + if user.share or self.env.context.get('force_website'): + try: + record.check_access_rights('read') + record.check_access_rule('read') + except AccessError: + pass + else: + return { + 'type': 'ir.actions.act_url', + 'url': '/my/opportunity/%s' % record.id, + } + return super(CrmLead, self).get_access_action(access_uid) diff --git a/addons/website_crm_partner_assign/tests/test_partner_assign.py b/addons/website_crm_partner_assign/tests/test_partner_assign.py index dde78e0d410..77e65d1b8d0 100644 --- a/addons/website_crm_partner_assign/tests/test_partner_assign.py +++ b/addons/website_crm_partner_assign/tests/test_partner_assign.py @@ -151,3 +151,8 @@ class TestPartnerLeadPortal(TestCrmCases): self.assertEqual(opportunity.team_id, salesmanteam, 'The created opportunity should have the same team as the salesman default team of the opportunity creator.') self.assertEqual(opportunity.partner_assigned_id, self.portal_partner, 'Assigned Partner of created opportunity is the (portal) creator.') + + def test_portal_mixin_url(self): + record_action = self.lead.get_access_action(self.portal_user.id) + self.assertEqual(record_action['url'], '/my/opportunity/%s' % self.lead.id) + self.assertEqual(record_action['type'], 'ir.actions.act_url') diff --git a/addons/website_sale/models/sale_order.py b/addons/website_sale/models/sale_order.py index 899d74e7439..f092cb5de57 100644 --- a/addons/website_sale/models/sale_order.py +++ b/addons/website_sale/models/sale_order.py @@ -110,11 +110,27 @@ class SaleOrder(models.Model): 'pricelist': order.pricelist_id.id, }) product = self.env['product.product'].with_context(product_context).browse(product_id) - pu = product.price - if order.pricelist_id and order.partner_id: - order_line = order._cart_find_product_line(product.id) - if order_line: - pu = self.env['account.tax']._fix_tax_included_price_company(pu, product.taxes_id, order_line[0].tax_id, self.company_id) + discount = 0 + + if order.pricelist_id.discount_policy == 'without_discount': + # This part is pretty much a copy-paste of the method '_onchange_discount' of + # 'sale.order.line'. + price, rule_id = order.pricelist_id.with_context(product_context).get_product_price_rule(product, qty or 1.0, order.partner_id) + pu, currency = request.env['sale.order.line'].with_context(product_context)._get_real_price_currency(product, rule_id, qty, product.uom_id, order.pricelist_id.id) + if pu != 0: + if order.pricelist_id.currency_id != currency: + # we need new_list_price in the same currency as price, which is in the SO's pricelist's currency + date = order.date_order or fields.Date.today() + pu = currency._convert(pu, order.pricelist_id.currency_id, order.company_id, date) + discount = (pu - price) / pu * 100 + if discount < 0: + discount = 0 + else: + pu = product.price + if order.pricelist_id and order.partner_id: + order_line = order._cart_find_product_line(product.id) + if order_line: + pu = self.env['account.tax']._fix_tax_included_price_company(pu, product.taxes_id, order_line[0].tax_id, self.company_id) return { 'product_id': product_id, @@ -122,6 +138,7 @@ class SaleOrder(models.Model): 'order_id': order_id, 'product_uom': product.uom_id.id, 'price_unit': pu, + 'discount': discount, } @api.multi diff --git a/addons/website_sale/static/src/js/website_sale_tour_buy.js b/addons/website_sale/static/src/js/website_sale_tour_buy.js index 34ef819b7e8..952f0cd5f00 100644 --- a/addons/website_sale/static/src/js/website_sale_tour_buy.js +++ b/addons/website_sale/static/src/js/website_sale_tour_buy.js @@ -32,15 +32,12 @@ tour.register('shop_buy_product', { trigger: 'label:contains(Steel) input', }, { + id: 'add_cart_step', content: "click on add to cart", extra_trigger: 'label:contains(Steel) input:propChecked', trigger: '#product_detail form[action^="/shop/cart/update"] .btn-primary', }, { - content: "click in modal on 'Proceed to checkout' button", - trigger: 'button:contains("Proceed to Checkout")', - }, - { content: "add suggested", extra_trigger: '#wrap:not(:has(#cart_products:contains("Storage Box")))', trigger: '.oe_cart:has(tr:contains("Storage Box")) a:contains("Add to Cart")', diff --git a/addons/website_sale/static/src/js/website_sale_tour_shop_custom_attribute_value.js b/addons/website_sale/static/src/js/website_sale_tour_shop_custom_attribute_value.js index bc651d2801f..7debff36dc9 100644 --- a/addons/website_sale/static/src/js/website_sale_tour_shop_custom_attribute_value.js +++ b/addons/website_sale/static/src/js/website_sale_tour_shop_custom_attribute_value.js @@ -3,8 +3,6 @@ odoo.define("website_sale.tour_shop_custom_attribute_value", function (require) var tour = require("web_tour.tour"); - var optionVariantImage; - tour.register("shop_custom_attribute_value", { url: "/shop?search=Customizable Desk", test: true, @@ -19,61 +17,10 @@ odoo.define("website_sale.tour_shop_custom_attribute_value", function (require) trigger: 'input.variant_custom_value', run: 'text Wood', }, { + id: 'add_cart_step', trigger: 'a:contains(Add to Cart)', run: 'click', }, { - trigger: 'div:contains(Custom: Wood)', - extra_trigger: '.js_product.in_cart.main_product', - run: function (){} // checks that Yep, it's wood! - }, { - trigger: 'button.js_add_cart_json:has(i.fa-plus)', - run: 'click', - }, { - trigger: 'div.oe_striked_price span:contains(750)', - run: function (){}, // check - }, { - trigger: 'span.oe_price span:contains(600)', - run: function (){}, // check - }, { - trigger: '.oe_optional_products_modal .js_product:eq(1) div:contains("Conference Chair (Steel)")', - run: function () { - optionVariantImage = $('.oe_optional_products_modal .js_product:eq(1) img.variant_image').attr('src'); - } - }, { - trigger: '.oe_optional_products_modal .js_product:eq(1) input[data-value_name="Aluminium"]', - }, { - trigger: '.oe_optional_products_modal .js_product:eq(1) div:contains("Conference Chair (Aluminium)")', - run: function () { - var newVariantImage = $('.oe_optional_products_modal .js_product:eq(1) img.variant_image').attr('src'); - if (newVariantImage !== optionVariantImage) { - $('<p>').text('image variant option src changed').insertAfter('.oe_optional_products_modal .js_product:eq(1) .product-name'); - } - } - }, { - extra_trigger: '.oe_optional_products_modal .js_product:eq(1) div:contains("image variant option src changed")', - trigger: '.oe_optional_products_modal .js_product:eq(1) input[data-value_name="Steel"]', - }, { - trigger: 'li.js_attribute_value span:contains(Aluminium)', - extra_trigger: '.oe_optional_products_modal', - run: 'click' - }, { - trigger: '.oe_price span:contains(22.90)', - run: function (){}, // check - }, { - trigger: '.oe_optional_products_modal .js_product:has(strong:contains(Conference Chair)) .js_add', - extra_trigger: '.oe_optional_products_modal .js_product:has(strong:contains(Conference Chair))', - run: 'click' - }, { - trigger: '.oe_optional_products_modal .js_product:has(strong:contains(Chair floor protection)) .js_add', - extra_trigger: '.oe_optional_products_modal .js_product:has(strong:contains(Chair floor protection))', - run: 'click' - }, { - trigger: 'span:contains(1,269.80)', - run: function (){}, // check - }, { - trigger: 'button:has(span:contains(Proceed to Checkout))', - run: 'click', - }, { trigger: 'span:contains(Custom: Wood)', extra_trigger: '#cart_products', run: function (){}, // check diff --git a/addons/website_sale_coupon/views/website_sale_templates.xml b/addons/website_sale_coupon/views/website_sale_templates.xml index 608912300cb..7032b3d76c5 100644 --- a/addons/website_sale_coupon/views/website_sale_templates.xml +++ b/addons/website_sale_coupon/views/website_sale_templates.xml @@ -20,6 +20,9 @@ <span class="js_quantity text-muted" t-esc="int(line.product_uom_qty)"/> </t> </xpath> + <xpath expr="//del" position="attributes"> + <attribute name="t-if">not line.is_reward_line</attribute> + </xpath> </template> <template id="sale_coupon_result" inherit_id="website_sale.coupon_form"> <xpath expr="//form[@name='coupon_code']" position="after"> diff --git a/addons/website_sale_product_configurator/static/src/js/website_sale_tour_buy.js b/addons/website_sale_product_configurator/static/src/js/website_sale_tour_buy.js new file mode 100644 index 00000000000..96915b14ed3 --- /dev/null +++ b/addons/website_sale_product_configurator/static/src/js/website_sale_tour_buy.js @@ -0,0 +1,19 @@ +odoo.define("website_sale_product_configurator.website_sale_tour", function (require) { +"use strict"; +/** + * Add custom steps to handle the optional products modal introduced + * by the product configurator module. + */ +var tour = require('web_tour.tour'); +require('website_sale.tour'); + +var addCartStepIndex = _.findIndex(tour.tours.shop_buy_product.steps, function (step) { + return (step.id === 'add_cart_step'); +}); + +tour.tours.shop_buy_product.steps.splice(addCartStepIndex + 1, 0, { + content: "click in modal on 'Proceed to checkout' button", + trigger: 'button:contains("Proceed to Checkout")', +}); + +}); diff --git a/addons/website_sale_product_configurator/static/src/js/website_sale_tour_shop_custom_attributes_value.js b/addons/website_sale_product_configurator/static/src/js/website_sale_tour_shop_custom_attributes_value.js new file mode 100644 index 00000000000..25549842eb0 --- /dev/null +++ b/addons/website_sale_product_configurator/static/src/js/website_sale_tour_shop_custom_attributes_value.js @@ -0,0 +1,69 @@ +odoo.define("website_sale_product_configurator.tour_shop_custom_attribute_value", function (require) { +"use strict"; +/** + * Add custom steps to handle the optional products modal introduced + * by the product configurator module. + */ +var tour = require('web_tour.tour'); +require('website_sale.tour_shop_custom_attribute_value'); + +var optionVariantImage; +var addCartStepIndex = _.findIndex(tour.tours.shop_custom_attribute_value.steps, function (step) { + return (step.id === 'add_cart_step'); +}); + +tour.tours.shop_custom_attribute_value.steps.splice(addCartStepIndex + 1, 0, { + trigger: 'div:contains(Custom: Wood)', + extra_trigger: '.js_product.in_cart.main_product', + run: function (){} // checks that Yep, it's wood! +}, { + trigger: 'button.js_add_cart_json:has(i.fa-plus)', + run: 'click', +}, { + trigger: 'div.oe_striked_price span:contains(750)', + run: function (){}, // check +}, { + trigger: 'span.oe_price span:contains(600)', + run: function (){}, // check +}, { + trigger: '.oe_optional_products_modal .js_product:eq(1) div:contains("Conference Chair (Steel)")', + run: function () { + optionVariantImage = $('.oe_optional_products_modal .js_product:eq(1) img.variant_image').attr('src'); + } +}, { + trigger: '.oe_optional_products_modal .js_product:eq(1) input[data-value_name="Aluminium"]', +}, { + trigger: '.oe_optional_products_modal .js_product:eq(1) div:contains("Conference Chair (Aluminium)")', + run: function () { + var newVariantImage = $('.oe_optional_products_modal .js_product:eq(1) img.variant_image').attr('src'); + if (newVariantImage !== optionVariantImage) { + $('<p>').text('image variant option src changed').insertAfter('.oe_optional_products_modal .js_product:eq(1) .product-name'); + } + } +}, { + extra_trigger: '.oe_optional_products_modal .js_product:eq(1) div:contains("image variant option src changed")', + trigger: '.oe_optional_products_modal .js_product:eq(1) input[data-value_name="Steel"]', +}, { + trigger: 'li.js_attribute_value span:contains(Aluminium)', + extra_trigger: '.oe_optional_products_modal', + run: 'click' +}, { + trigger: '.oe_price span:contains(22.90)', + run: function (){}, // check +}, { + trigger: '.oe_optional_products_modal .js_product:has(strong:contains(Conference Chair)) .js_add', + extra_trigger: '.oe_optional_products_modal .js_product:has(strong:contains(Conference Chair))', + run: 'click' +}, { + trigger: '.oe_optional_products_modal .js_product:has(strong:contains(Chair floor protection)) .js_add', + extra_trigger: '.oe_optional_products_modal .js_product:has(strong:contains(Chair floor protection))', + run: 'click' +}, { + trigger: 'span:contains(1,269.80)', + run: function (){}, // check +}, { + trigger: 'button:has(span:contains(Proceed to Checkout))', + run: 'click', +}); + +}); diff --git a/addons/website_sale_product_configurator/views/assets.xml b/addons/website_sale_product_configurator/views/assets.xml index 3cbe8fff468..e6344530e53 100644 --- a/addons/website_sale_product_configurator/views/assets.xml +++ b/addons/website_sale_product_configurator/views/assets.xml @@ -11,6 +11,8 @@ </xpath> <xpath expr="script[last()]" position="after"> <script type="text/javascript" src="/website_sale_product_configurator/static/src/js/website_sale_options.js"></script> + <script type="text/javascript" src="/website_sale_product_configurator/static/src/js/website_sale_tour_buy.js"></script> + <script type="text/javascript" src="/website_sale_product_configurator/static/src/js/website_sale_tour_shop_custom_attributes_value.js"></script> </xpath> </template> </odoo> diff --git a/doc/cla/corporate/business-solutions-for-africa.md b/doc/cla/corporate/business-solutions-for-africa.md new file mode 100644 index 00000000000..7619f7e2471 --- /dev/null +++ b/doc/cla/corporate/business-solutions-for-africa.md @@ -0,0 +1,15 @@ +The Democratic Republic of Congo, April 3rd 2019 + +Business Solutions For Africa agrees to the terms of the Odoo Corporate Contributor License +Agreement v1.0. + +I declare that I am authorized and able to make this agreement and sign this +declaration. + +Signed, + +Vincent Luba vincent@biz-4-africa.com https://github.com/BIZ4Africa + +List of contributors: + +Vincent Luba vincent@biz-4-africa.com https://github.com/BIZ4Africa diff --git a/doc/cla/corporate/xoe-corp.md b/doc/cla/corporate/xoe-corp.md index ef449e25d72..cb0e7d5eb42 100644 --- a/doc/cla/corporate/xoe-corp.md +++ b/doc/cla/corporate/xoe-corp.md @@ -13,3 +13,4 @@ David Arnold dar@xoe.solutions https://github.com/blaggacao List of contributors: David Arnold dar@devco.co https://github.com/blaggacao +Deiber Rincon dri@xoe.solutions https://github.com/deiber diff --git a/doc/cla/individual/andriisem.md b/doc/cla/individual/andriisem.md new file mode 100644 index 00000000000..da69e5db958 --- /dev/null +++ b/doc/cla/individual/andriisem.md @@ -0,0 +1,9 @@ +Ukraine, 2019-04-08 + +I hereby agree to the terms of the Odoo Individual Contributor License Agreement v1.0. + +I declare that I am authorized and able to make this agreement and sign this declaration. + +Signed, + +Andrii Semko semko.andrey.i@gmail.com https://github.com/andriisem
\ No newline at end of file diff --git a/doc/cla/individual/bletourmy.md b/doc/cla/individual/bletourmy.md new file mode 100644 index 00000000000..1d51fcaf71d --- /dev/null +++ b/doc/cla/individual/bletourmy.md @@ -0,0 +1,11 @@ +Switzerland, 2019-03-25 + +I hereby agree to the terms of the Odoo Individual Contributor License +Agreement v1.0. + +I declare that I am authorized and able to make this agreement and sign this +declaration. + +Signed, + +Bernard Letourmy bernard.letourmy@gmail.com https://github.com/bletourmy diff --git a/doc/cla/individual/hviniciusg.md b/doc/cla/individual/hviniciusg.md new file mode 100644 index 00000000000..d98b0703a49 --- /dev/null +++ b/doc/cla/individual/hviniciusg.md @@ -0,0 +1,11 @@ +Bolivia, 2019-03-31 + +I hereby agree to the terms of the Odoo Individual Contributor License +Agreement v1.0. + +I declare that I am authorized and able to make this agreement and sign this +declaration. + +Signed, + +Hugo Vinicius Garcia Razeara hviniciusg@gmail.com https://github.com/hviniciusg diff --git a/doc/cla/individual/matthewdwatkins.md b/doc/cla/individual/matthewdwatkins.md new file mode 100644 index 00000000000..75f773aa5a4 --- /dev/null +++ b/doc/cla/individual/matthewdwatkins.md @@ -0,0 +1,11 @@ +USA, 2019-04-04 + +I hereby agree to the terms of the Odoo Individual Contributor License +Agreement v1.0. + +I declare that I am authorized and able to make this agreement and sign this +declaration. + +Signed, + +Matthew Watkins mwatkins.bulk@gmail.com https://github.com/matthewdwatkins diff --git a/doc/cla/individual/mmahgoub.md b/doc/cla/individual/mmahgoub.md new file mode 100644 index 00000000000..e74845bff23 --- /dev/null +++ b/doc/cla/individual/mmahgoub.md @@ -0,0 +1,11 @@ +Sudan, 2019-03-15 + +I hereby agree to the terms of the Odoo Individual Contributor License +Agreement v1.0. + +I declare that I am authorized and able to make this agreement and sign this +declaration. + +Signed, + +Mohammed Mahgoub mmahgoub@gmail.com https://github.com/mmahgoub diff --git a/doc/howtos/themes.rst b/doc/howtos/themes.rst index f4f18dff759..89695326b7d 100644 --- a/doc/howtos/themes.rst +++ b/doc/howtos/themes.rst @@ -277,7 +277,11 @@ Installing your theme To install your theme, you just place your theme folder inside addons in your Odoo installation. -After that, navigate to the Settings page, look for your theme and click on the install button. +After that, navigate to the Odoo **Website** module, go to +:menuselection:`Configuration --> Settings`. + +Under **Website** section click the **Choose a theme** button, then hover over +your theme and click **Use this theme**. Structure of an Odoo page ========================= diff --git a/odoo/addons/base/data/res.lang.csv b/odoo/addons/base/data/res.lang.csv index b28e9dfc5d6..7f864be4fbe 100644 --- a/odoo/addons/base/data/res.lang.csv +++ b/odoo/addons/base/data/res.lang.csv @@ -1,7 +1,7 @@ "id","name","code","iso_code","direction","grouping","decimal_point","thousands_sep","date_format","time_format","translatable","week_start" "base.lang_en","English (US)","en_US","en","Left-to-Right","[3,0]",".",",","%m/%d/%Y","%H:%M:%S","True","7" "base.lang_am_ET","Amharic / አምሃርኛ","am_ET","am_ET","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%I:%M:%S","True","7" -"base.lang_ar","Arabic / الْعَرَبيّة","ar_AA","ar","Right-to-Left","[3,0]",".",",","%d %b, %Y","%I:%M:%S","True","6" +"base.lang_ar","Arabic / الْعَرَبيّة","ar_001","ar","Right-to-Left","[3,0]",".",",","%d %b, %Y","%I:%M:%S","True","6" "base.lang_ar_SY","Arabic (Syria) / الْعَرَبيّة","ar_SY","ar_SY","Right-to-Left","[3,0]",".",",","%d %b, %Y","%I:%M:%S","True","6" "base.lang_eu_ES","Basque / Euskara","eu_ES","eu_ES","Left-to-Right","[]",",",,"%a, %Y.eko %bren %da","%H:%M:%S","True","1" "base.lang_bn_IN","Bengali / বাংলা","bn_IN","bn_IN","Left-to-Right","[]",",",,"%A %d %b %Y","%I:%M:%S","True","1" diff --git a/odoo/addons/base/models/ir_mail_server.py b/odoo/addons/base/models/ir_mail_server.py index b7597ebaa77..6773e1f61cc 100644 --- a/odoo/addons/base/models/ir_mail_server.py +++ b/odoo/addons/base/models/ir_mail_server.py @@ -483,6 +483,8 @@ class IrMailServer(models.Model): # do not quit() a pre-established smtp_session if not smtp_session: smtp.quit() + except smtplib.SMTPServerDisconnected: + raise except Exception as e: params = (ustr(smtp_server), e.__class__.__name__, ustr(e)) msg = _("Mail delivery failed via SMTP server '%s'.\n%s: %s") % params diff --git a/odoo/addons/base/models/ir_model.py b/odoo/addons/base/models/ir_model.py index eb03dfcafcd..b7a923dfa49 100644 --- a/odoo/addons/base/models/ir_model.py +++ b/odoo/addons/base/models/ir_model.py @@ -185,6 +185,11 @@ class IrModel(models.Model): self._cr.execute('DROP VIEW "%s"' % table) elif kind == 'r': self._cr.execute('DROP TABLE "%s" CASCADE' % table) + # discard all translations for this model + self._cr.execute(""" + DELETE FROM ir_translation + WHERE type IN ('model', 'model_terms') AND name LIKE %s + """, [model.model + ',%']) else: _logger.warning('The model %s could not be dropped because it did not exist in the registry.', model.model) return True @@ -574,6 +579,12 @@ class IrModelFields(models.Model): tables_to_drop.add(rel_name) if field.state == 'manual' and is_model: model._pop_field(field.name) + if field.translate: + # discard all translations for this field + self._cr.execute(""" + DELETE FROM ir_translation + WHERE type IN ('model', 'model_terms') AND name=%s + """, ['%s,%s' % (field.model, field.name)]) if tables_to_drop: # drop the relation tables that are not used by other fields diff --git a/odoo/addons/base/models/ir_module.py b/odoo/addons/base/models/ir_module.py index 27c81dd2702..ddccfb2c3ca 100644 --- a/odoo/addons/base/models/ir_module.py +++ b/odoo/addons/base/models/ir_module.py @@ -19,6 +19,7 @@ from docutils.core import publish_string from docutils.transforms import Transform, writer_aux from docutils.writers.html4css1 import Writer import lxml.html +import psycopg2 import odoo from odoo import api, fields, models, modules, tools, _ @@ -542,6 +543,14 @@ class Module(models.Model): @api.multi def _button_immediate_function(self, function): + try: + # This is done because the installation/uninstallation/upgrade can modify a currently + # running cron job and prevent it from finishing, and since the ir_cron table is locked + # during execution, the lock won't be released until timeout. + self._cr.execute("SELECT * FROM ir_cron FOR UPDATE NOWAIT") + except psycopg2.OperationalError: + raise UserError(_("The server is busy right now, module operations are not possible at" + " this time, please try again later.")) function(self) self._cr.commit() diff --git a/odoo/addons/base/models/ir_translation.py b/odoo/addons/base/models/ir_translation.py index 2d9744abada..bf560adf467 100644 --- a/odoo/addons/base/models/ir_translation.py +++ b/odoo/addons/base/models/ir_translation.py @@ -670,6 +670,47 @@ class IrTranslation(models.Model): self._modified_model(field.model_name) @api.model + def _upsert_translations(self, vals_list): + """ Insert or update translations of type 'model' or 'model_terms'. + + This method is used for creations of translations where the given + ``vals_list`` is trusted to be the right values and potential + conflicts should be updated to the new given value. + """ + rows_by_type = defaultdict(list) + for vals in vals_list: + rows_by_type[vals['type']].append(( + vals['name'], vals['lang'], vals['res_id'], vals['src'], vals['type'], + vals.get('module'), vals['value'], vals.get('state'), vals.get('comments'), + )) + + if rows_by_type['model']: + query = """ + INSERT INTO ir_translation (name, lang, res_id, src, type, + module, value, state, comments) + VALUES {} + ON CONFLICT (type, lang, name, res_id) WHERE type='model' + DO UPDATE SET (name, lang, res_id, src, type, value, module, state, comments) = + (EXCLUDED.name, EXCLUDED.lang, EXCLUDED.res_id, EXCLUDED.src, EXCLUDED.type, + EXCLUDED.value, EXCLUDED.module, EXCLUDED.state, EXCLUDED.comments) + WHERE EXCLUDED.value IS NOT NULL AND EXCLUDED.value != ''; + """.format(", ".join(["%s"] * len(rows_by_type['model']))) + self.env.cr.execute(query, rows_by_type['model']) + + if rows_by_type['model_terms']: + query = """ + INSERT INTO ir_translation (name, lang, res_id, src, type, + module, value, state, comments) + VALUES {} + ON CONFLICT (type, name, lang, res_id, md5(src)) + DO UPDATE SET (name, lang, res_id, src, type, value, module, state, comments) = + (EXCLUDED.name, EXCLUDED.lang, EXCLUDED.res_id, EXCLUDED.src, EXCLUDED.type, + EXCLUDED.value, EXCLUDED.module, EXCLUDED.state, EXCLUDED.comments) + WHERE EXCLUDED.value IS NOT NULL AND EXCLUDED.value != ''; + """.format(", ".join(["%s"] * len(rows_by_type['model_terms']))) + self.env.cr.execute(query, rows_by_type['model_terms']) + + @api.model def translate_fields(self, model, id, field=None): """ Open a view for translating the field(s) of the record (model, id). """ main_lang = 'en_US' diff --git a/odoo/addons/base/models/res_bank.py b/odoo/addons/base/models/res_bank.py index a4e75a2532c..d9a0433ecc2 100644 --- a/odoo/addons/base/models/res_bank.py +++ b/odoo/addons/base/models/res_bank.py @@ -66,7 +66,7 @@ class ResPartnerBank(models.Model): _name = 'res.partner.bank' _rec_name = 'acc_number' _description = 'Bank Accounts' - _order = 'sequence' + _order = 'sequence, id' @api.model def get_supported_account_types(self): @@ -84,7 +84,7 @@ class ResPartnerBank(models.Model): bank_id = fields.Many2one('res.bank', string='Bank') bank_name = fields.Char(related='bank_id.name', readonly=False) bank_bic = fields.Char(related='bank_id.bic', readonly=False) - sequence = fields.Integer() + sequence = fields.Integer(default=10) currency_id = fields.Many2one('res.currency', string='Currency') company_id = fields.Many2one('res.company', 'Company', default=lambda self: self.env.user.company_id, ondelete='cascade') qr_code_valid = fields.Boolean(string="Has all required arguments", compute="_validate_qr_code_arguments") diff --git a/odoo/addons/base/models/res_currency.py b/odoo/addons/base/models/res_currency.py index c75ddfe0021..09a6d019f38 100644 --- a/odoo/addons/base/models/res_currency.py +++ b/odoo/addons/base/models/res_currency.py @@ -115,7 +115,7 @@ class Currency(models.Model): fractional_value = int(parts[2] or 0) lang_code = self.env.context.get('lang') or self.env.user.lang - lang = self.env['res.lang'].search([('code', '=', lang_code)]) + lang = self.env['res.lang'].with_context(active_test=False).search([('code', '=', lang_code)]) amount_words = tools.ustr('{amt_value} {amt_word}').format( amt_value=_num2words(integer_value, lang=lang.iso_code), amt_word=self.currency_unit_label, diff --git a/odoo/addons/base/models/res_partner.py b/odoo/addons/base/models/res_partner.py index 603fbd6c4d2..49979b9e2c5 100644 --- a/odoo/addons/base/models/res_partner.py +++ b/odoo/addons/base/models/res_partner.py @@ -2,6 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. import base64 +import collections import datetime import hashlib import pytz @@ -286,8 +287,32 @@ class Partner(models.Model): @api.depends('is_company', 'parent_id.commercial_partner_id') def _compute_commercial_partner(self): + self.env.cr.execute(""" + WITH RECURSIVE cpid(id, parent_id, commercial_partner_id, final) AS ( + SELECT + id, parent_id, id, + (coalesce(is_company, false) OR parent_id IS NULL) as final + FROM res_partner + WHERE id = ANY(%s) + UNION + SELECT + cpid.id, p.parent_id, p.id, + (coalesce(is_company, false) OR p.parent_id IS NULL) as final + FROM res_partner p + JOIN cpid ON (cpid.parent_id = p.id) + WHERE NOT cpid.final + ) + SELECT cpid.id, cpid.commercial_partner_id + FROM cpid + WHERE final AND id = ANY(%s); + """, [self.ids, self.ids]) + + d = dict(self.env.cr.fetchall()) for partner in self: - if partner.is_company or not partner.parent_id: + fetched = d.get(partner.id) + if fetched is not None: + partner.commercial_partner_id = fetched + elif partner.is_company or not partner.parent_id: partner.commercial_partner_id = partner else: partner.commercial_partner_id = partner.parent_id.commercial_partner_id @@ -483,21 +508,25 @@ class Partner(models.Model): self.update_address(onchange_vals) # 2. To DOWNSTREAM: sync children - if self.child_ids: - # 2a. Commercial Fields: sync if commercial entity - if self.commercial_partner_id == self: - commercial_fields = self._commercial_fields() - if any(field in values for field in commercial_fields): - self._commercial_sync_to_children() - for child in self.child_ids.filtered(lambda c: not c.is_company): - if child.commercial_partner_id != self.commercial_partner_id : - self._commercial_sync_to_children() - break - # 2b. Address fields: sync if address changed - address_fields = self._address_fields() - if any(field in values for field in address_fields): - contacts = self.child_ids.filtered(lambda c: c.type == 'contact') - contacts.update_address(values) + self._children_sync(values) + + def _children_sync(self, values): + if not self.child_ids: + return + # 2a. Commercial Fields: sync if commercial entity + if self.commercial_partner_id == self: + commercial_fields = self._commercial_fields() + if any(field in values for field in commercial_fields): + self._commercial_sync_to_children() + for child in self.child_ids.filtered(lambda c: not c.is_company): + if child.commercial_partner_id != self.commercial_partner_id: + self._commercial_sync_to_children() + break + # 2b. Address fields: sync if address changed + address_fields = self._address_fields() + if any(field in values for field in address_fields): + contacts = self.child_ids.filtered(lambda c: c.type == 'contact') + contacts.update_address(values) @api.multi def _handle_first_contact_creation(self): @@ -574,11 +603,53 @@ class Partner(models.Model): tools.image_resize_images(vals, return_big=False) tools.image_resize_images(vals, return_medium=False, return_small=False, preserve_aspect_ratio=True) partners = super(Partner, self).create(vals_list) + + if self.env.context.get('_partners_skip_fields_sync'): + return partners + for partner, vals in zip(partners, vals_list): partner._fields_sync(vals) partner._handle_first_contact_creation() return partners + def _load_records_create(self, vals_list): + partners = super(Partner, self.with_context(_partners_skip_fields_sync=True))._load_records_create(vals_list) + + # batch up first part of _fields_sync + # group partners by commercial_partner_id (if not self) and parent_id (if type == contact) + groups = collections.defaultdict(list) + for partner, vals in zip(partners, vals_list): + cp_id = None + if vals.get('parent_id') and partner.commercial_partner_id != partner: + cp_id = partner.commercial_partner_id.id + + add_id = None + if partner.parent_id and partner.type == 'contact': + add_id = partner.parent_id.id + groups[(cp_id, add_id)].append(partner.id) + + for (cp_id, add_id), children in groups.items(): + # values from parents (commercial, regular) written to their common children + to_write = {} + # commercial fields from commercial partner + if cp_id: + to_write = self.browse(cp_id)._update_fields_values(self._commercial_fields()) + # address fields from parent + if add_id: + parent = self.browse(add_id) + for f in self._address_fields(): + v = parent[f] + if v: + to_write[f] = v.id if isinstance(v, models.BaseModel) else v + if to_write: + self.browse(children).write(to_write) + + # do the second half of _fields_sync the "normal" way + for partner, vals in zip(partners, vals_list): + partner._children_sync(vals) + partner._handle_first_contact_creation() + return partners + @api.multi def create_company(self): self.ensure_one() diff --git a/odoo/addons/base/tests/test_base.py b/odoo/addons/base/tests/test_base.py index 5e1a24958bf..a823c65dc31 100644 --- a/odoo/addons/base/tests/test_base.py +++ b/odoo/addons/base/tests/test_base.py @@ -252,6 +252,41 @@ class TestBase(TransactionCase): self.assertEqual(leaf111.address_get([]), {'contact': branch11.id}, 'Invalid address resolution, branch11 should now be contact') + def test_commercial_partner_nullcompany(self): + """ The commercial partner is the first/nearest ancestor-or-self which + is a company or doesn't have a parent + """ + P = self.env['res.partner'] + p0 = P.create({'name': '0', 'email': '0'}) + self.assertEqual(p0.commercial_partner_id, p0, "partner without a parent is their own commercial partner") + + p1 = P.create({'name': '1', 'email': '1', 'parent_id': p0.id}) + self.assertEqual(p1.commercial_partner_id, p0, "partner's parent is their commercial partner") + p12 = P.create({'name': '12', 'email': '12', 'parent_id': p1.id}) + self.assertEqual(p12.commercial_partner_id, p0, "partner's GP is their commercial partner") + + p2 = P.create({'name': '2', 'email': '2', 'parent_id': p0.id, 'is_company': True}) + self.assertEqual(p2.commercial_partner_id, p2, "partner flagged as company is their own commercial partner") + p21 = P.create({'name': '21', 'email': '21', 'parent_id': p2.id}) + self.assertEqual(p21.commercial_partner_id, p2, "commercial partner is closest ancestor with themselves as commercial partner") + + p3 = P.create({'name': '3', 'email': '3', 'is_company': True}) + self.assertEqual(p3.commercial_partner_id, p3, "being both parent-less and company should be the same as either") + + notcompanies = p0 | p1 | p12 | p21 + self.env.cr.execute('update res_partner set is_company=null where id = any(%s)', [notcompanies.ids]) + for parent in notcompanies: + p = P.create({ + 'name': parent.name + '_sub', + 'email': parent.email + '_sub', + 'parent_id': parent.id, + }) + self.assertEqual( + p.commercial_partner_id, + parent.commercial_partner_id, + "check that is_company=null is properly handled when looking for ancestor" + ) + def test_50_res_partner_commercial_sync(self): res_partner = self.env['res.partner'] p0 = res_partner.create({'name': 'Sigurd Sunknife', diff --git a/odoo/addons/base/views/res_bank_views.xml b/odoo/addons/base/views/res_bank_views.xml index b83bcb46222..dcec863abfa 100644 --- a/odoo/addons/base/views/res_bank_views.xml +++ b/odoo/addons/base/views/res_bank_views.xml @@ -69,6 +69,7 @@ <form string="Bank account"> <group> <group> + <field name="sequence" invisible="1"/> <field name="acc_number"/> <field name="acc_type"/> <field name="company_id" groups="base.group_multi_company" options="{'no_create': True}"/> @@ -89,7 +90,7 @@ <field name="model">res.partner.bank</field> <field name="arch" type="xml"> <tree string="Bank Accounts"> - <field name="sequence" invisible="1"/> + <field name="sequence" widget="handle"/> <field name="acc_number"/> <field name="bank_name"/> <field name="company_id" groups="base.group_multi_company"/> diff --git a/odoo/addons/test_main_flows/static/src/js/tour.js b/odoo/addons/test_main_flows/static/src/js/tour.js index 4b08c5b1b22..5113a81dc6e 100644 --- a/odoo/addons/test_main_flows/static/src/js/tour.js +++ b/odoo/addons/test_main_flows/static/src/js/tour.js @@ -773,7 +773,7 @@ tour.register('main_flow_tour', { position: "right", }, { edition: "enterprise", - trigger: ".button_close_statement", + trigger: ".button_back_to_statement", content: _t('<p><b>Close this statement.</p>'), position: "bottom", }]); diff --git a/odoo/addons/test_testing_utilities/ir.model.access.csv b/odoo/addons/test_testing_utilities/ir.model.access.csv index c069b568ad4..c43b736c531 100644 --- a/odoo/addons/test_testing_utilities/ir.model.access.csv +++ b/odoo/addons/test_testing_utilities/ir.model.access.csv @@ -12,7 +12,10 @@ access_test_testing_utilities_parent,access_test_testing_utilities_parent,model_ access_test_testing_utilities_sub,access_test_testing_utilities_sub,model_test_testing_utilities_sub,,1,0,0,0 access_test_testing_utilities_sub2,access_test_testing_utilities_sub2,model_test_testing_utilities_sub2,,1,0,0,0 access_test_testing_utilities_sub3,access_test_testing_utilities_sub3,model_test_testing_utilities_sub3,,1,0,0,0 +access_test_testing_utilities_recursive,access_test_testing_utilities_recursive,model_test_testing_utilities_recursive,,1,0,0,0 access_test_testing_utilities_onchange_parent,access_test_testing_utilities_onchange_parent,model_test_testing_utilities_onchange_parent,,1,0,0,0 access_test_testing_utilities_onchange_line,access_test_testing_utilities_onchange_line,model_test_testing_utilities_onchange_line,,1,0,0,0 access_model_test_testing_utilities_onchange_count,access_model_test_testing_utilities_onchange_count,model_test_testing_utilities_onchange_count,,1,1,1,1 access_test_testing_utilities_onchange_count_sub,access_test_testing_utilities_onchange_count_sub,model_test_testing_utilities_onchange_count_sub,,1,1,1,1 +access_o2m_readonly_subfield_parent,access_o2m_readonly_subfield_parent,model_o2m_readonly_subfield_parent,,1,1,1,1 +access_o2m_readonly_subfield_child,access_o2m_readonly_subfield_child,model_o2m_readonly_subfield_child,,1,1,1,1 diff --git a/odoo/addons/test_testing_utilities/menu.xml b/odoo/addons/test_testing_utilities/menu.xml index fa5f4f37163..491a74dda46 100644 --- a/odoo/addons/test_testing_utilities/menu.xml +++ b/odoo/addons/test_testing_utilities/menu.xml @@ -151,4 +151,17 @@ </form> </field> </record> + + <record id="o2m_recursive_relation_view" model="ir.ui.view"> + <field name="name">Recursive Relation Test View</field> + <field name="model">test_testing_utilities.recursive</field> + <field name="arch" type="xml"> + <form> + <sheet> + <field name="one_to_many_id"/> + <field name="many_to_one_ids"/> + </sheet> + </form> + </field> + </record> </odoo> diff --git a/odoo/addons/test_testing_utilities/models.py b/odoo/addons/test_testing_utilities/models.py index fbdec47a7cf..0c04bc06e8d 100644 --- a/odoo/addons/test_testing_utilities/models.py +++ b/odoo/addons/test_testing_utilities/models.py @@ -188,6 +188,13 @@ class O2MSub3(models.Model): r.name = str(r.v) +class O2MRecursive(models.Model): + _name = _description = 'test_testing_utilities.recursive' + + one_to_many_id = fields.Many2one('test_testing_utilities.recursive', readonly=True) + many_to_one_ids = fields.One2many('test_testing_utilities.recursive', 'one_to_many_id', readonly=True) + + class O2MOnchangeParent(models.Model): _name = 'test_testing_utilities.onchange_parent' _description = 'Testing Utilities Onchange Parent' @@ -233,3 +240,24 @@ class O2MChangeSub(models.Model): parent = fields.Many2one('test_testing_utilities.onchange_count') name = fields.Char() + +class O2MReadonlySubfield(models.Model): + _name = 'o2m_readonly_subfield_parent' + _description = _name + + line_ids = fields.One2many('o2m_readonly_subfield_child', 'parent_id') + +class O2MReadonlySubfieldChild(models.Model): + _name = _description = 'o2m_readonly_subfield_child' + + name = fields.Char() + parent_id = fields.Many2one('o2m_readonly_subfield_parent') + f = fields.Integer(compute='_compute_f', inverse='_inverse_f', readonly=True) + + @api.depends('name') + def _compute_f(self): + for r in self: + r.f = len(r.name) if r.name else 0 + + def _inverse_f(self): + raise AssertionError("Inverse of f should not be called") diff --git a/odoo/addons/test_testing_utilities/tests/test_form_impl.py b/odoo/addons/test_testing_utilities/tests/test_form_impl.py index c13dbd73a60..0191a4da7ad 100644 --- a/odoo/addons/test_testing_utilities/tests/test_form_impl.py +++ b/odoo/addons/test_testing_utilities/tests/test_form_impl.py @@ -359,7 +359,7 @@ class TestO2M(TransactionCase): self.assertEqual( [get(s) for s in r.subs], - [("5", 2, 5)] + [("5", 0, 5)] ) def test_o2m_inner_default(self): @@ -409,7 +409,10 @@ class TestO2M(TransactionCase): self.assertEqual(sub.value, 1) self.assertEqual(sub.v, 1) - def test_m2o_readonly(self): + def test_readonly_o2m(self): + """ Tests that o2m fields flagged as readonly (readonly="1" in the + view) can't be written to + """ r = self.env['test_testing_utilities.parent'].create({ 'subs': [(0, 0, {})] }) @@ -422,6 +425,20 @@ class TestO2M(TransactionCase): with self.assertRaises(AssertionError): f.subs.remove(index=0) + def test_o2m_readonly_subfield(self): + """ Tests that readonly is applied to the field of the o2m = not sent + as part of the create / write values + """ + f = Form(self.env['o2m_readonly_subfield_parent']) + with f.line_ids.new() as new_line: + new_line.name = "ok" + self.assertEqual(new_line.f, 2) + r = f.save() + self.assertEqual( + (r.line_ids.name, r.line_ids.f), + ('ok', 2) + ) + def test_o2m_dyn_onchange(self): f = Form(self.env['test_testing_utilities.onchange_parent'], view='test_testing_utilities.m2o_onchange_view') @@ -467,6 +484,9 @@ class TestO2M(TransactionCase): self.assertEqual(commands(), [0, 2, 2], "should contain 1 '0' command and 2 deletions") self.assertEqual(len(r.line_ids), 1) + def test_o2m_self_recursive(self): + Form(self.env['test_testing_utilities.recursive'], view='test_testing_utilities.o2m_recursive_relation_view') + class TestEdition(TransactionCase): """ These use the context manager form as we don't need the record post-save (we already have it) and it's easier to see what bits act on diff --git a/odoo/fields.py b/odoo/fields.py index 8d977e6cde3..5d0cf903232 100644 --- a/odoo/fields.py +++ b/odoo/fields.py @@ -945,7 +945,11 @@ class Field(MetaField('DummyField', (object,), {})): """ indexname = '%s_%s_index' % (model._table, self.name) if self.index: - sql.create_index(model._cr, indexname, model._table, ['"%s"' % self.name]) + try: + with model._cr.savepoint(): + sql.create_index(model._cr, indexname, model._table, ['"%s"' % self.name]) + except psycopg2.OperationalError: + _schema.error("Unable to add index for %s", self) else: sql.drop_index(model._cr, indexname, model._table) diff --git a/odoo/models.py b/odoo/models.py index 7178f27f24c..8ebd58f4b1d 100644 --- a/odoo/models.py +++ b/odoo/models.py @@ -4311,11 +4311,12 @@ Fields: if vals['lang'] == old.env.lang and field.translate is True: # force a source if the new_val was not changed by copy override if new_val == old[name]: - vals['source'] = old_wo_lang[name] + new_wo_lang[name] = old_wo_lang[name] + vals['src'] = old_wo_lang[name] # the value should be the new value (given by copy()) vals['value'] = new_val vals_list.append(vals) - Translation.create(vals_list) + Translation._upsert_translations(vals_list) @api.multi @api.returns('self', lambda value: value.id) diff --git a/odoo/tests/common.py b/odoo/tests/common.py index aaad8b8e9e5..fade4e747cb 100644 --- a/odoo/tests/common.py +++ b/odoo/tests/common.py @@ -1126,7 +1126,7 @@ class Form(object): else: self._init_from_defaults(self._model) - def _o2m_set_edition_view(self, descr, node): + def _o2m_set_edition_view(self, descr, node, level): default_view = next( (m for m in node.get('mode', 'tree').split(',') if m != 'form'), 'tree' @@ -1154,17 +1154,10 @@ class Form(object): edition = views['tree'] edition['tree'] = subarch - self._process_fvg(submodel, edition) + # don't recursively process o2ms in o2ms + self._process_fvg(submodel, edition, level=level-1) descr['views']['edition'] = edition - def _get_node(self, f): - """ Find etree node for the field ``f`` in the current arch - """ - return next( - n for n in self._view['tree'].iter('field') - if n.get('name') == f - ) - def __str__(self): return "<%s %s(%s)>" % ( type(self).__name__, @@ -1172,16 +1165,16 @@ class Form(object): self._values.get('id', False), ) - def _process_fvg(self, model, fvg): + def _process_fvg(self, model, fvg, level=2): """ Post-processes to augment the fields_view_get with: * an id field (may not be present if not in the view but needed) * pre-processed modifiers (map of modifier name to json-loaded domain) * pre-processed onchanges list """ - fvg['fields']['id'] = {'type': 'id'} + fvg['fields'].setdefault('id', {'type': 'id'}) # pre-resolve modifiers & bind to arch toplevel - modifiers = fvg['modifiers'] = {} + modifiers = fvg['modifiers'] = {'id': {'required': False, 'readonly': True}} contexts = fvg['contexts'] = {} order = fvg['fields_ordered'] = [] for f in fvg['tree'].xpath('//field[not(ancestor::field)]'): @@ -1197,10 +1190,9 @@ class Form(object): contexts[fname] = ctx descr = fvg['fields'].get(fname) or {'type': None} - if descr['type'] == 'one2many': - self._o2m_set_edition_view(descr, f) + if level and descr['type'] == 'one2many': + self._o2m_set_edition_view(descr, f, level) - fvg['modifiers']['id'] = {'required': False, 'readonly': True} fvg['onchange'] = model._onchange_spec(fvg) def _init_from_defaults(self, model): @@ -1258,12 +1250,13 @@ class Form(object): return O2MProxy(self, field) return v - def _get_modifier(self, field, modifier, default=False): - d = self._view['modifiers'][field].get(modifier, default) + def _get_modifier(self, field, modifier, default=False, modmap=None, vals=None): + d = (modmap or self._view['modifiers'])[field].get(modifier, default) if isinstance(d, bool): return d - vals = self._values + if vals is None: + vals = self._values stack = [] for it in reversed(d): if it == '!': @@ -1278,7 +1271,9 @@ class Form(object): stack.append(e1 or e2) elif isinstance(it, list): f, op, val = it - field_val = vals[f] + # hack-ish handling of parent.<field> modifiers + f, n = re.subn(r'^parent\.', '', f, 1) + field_val = (vals['•parent•'] if n else vals)[f] stack.append(self._OPS[op](field_val, val)) else: raise ValueError("Unknown domain element %s" % it) @@ -1377,7 +1372,8 @@ class Form(object): load/save """ values = {} - for f in self._view['fields']: + fields = self._view['fields'] + for f in fields: v = self._values[f] if self._get_modifier(f, 'required'): assert v is not False, "{} is a required field".format(f) @@ -1387,10 +1383,34 @@ class Form(object): continue if self._get_modifier(f, 'readonly'): - node = self._get_node(f) + node = _get_node(self._view, f) if not node.get('force_save'): continue + if fields[f]['type'] == 'one2many': + view = fields[f]['views']['edition'] + modifiers = view['modifiers'] + oldvals = v + v = [] + + nodes = { + n.get('name'): n + for n in view['tree'].iter('field') + } + nodes['id'] = etree.Element('field', attrib={'name': 'id'}) + + for (c, rid, vs) in oldvals: + if c in (0, 1): + items = list(getattr(vs, 'changed_items', vs.items)()) + # FIXME: should be more extensive processing of o2m defaults + vs.setdefault('id', False) + vs['•parent•'] = self._values + vs = { + k: v for k, v in items + if nodes[k].get('force_save') or not self._get_modifier(k, 'readonly', modmap=modifiers, vals=vs) + } + v.append((c, rid, vs)) + values[f] = v return values @@ -1425,11 +1445,15 @@ class Form(object): values = {} for k, v in self._values.items(): if f[k]['type'] == 'one2many': - # web client sends a 4 for unmodified o2m rows - values[k] = [ - (4, rid, False) if (c == 1 and not vs) else (c, rid, vs) - for (c, rid, vs) in v - ] + it = values[k] = [] + for (c, rid, vs) in v: + if c == 1 and not vs: + # web client sends a 4 for unmodified o2m rows + it.append((4, rid, False)) + elif c == 1 and isinstance(vs, UpdateDict): + it.append((1, rid, dict(vs.changed_items()))) + else: + it.append((c, rid, vs)) else: values[k] = v return values @@ -1446,12 +1470,13 @@ class Form(object): return [] v = [] - c = {t[1]: t[2] for t in current if t[0] in (1, 2)} if current else {} + c = {t[1] for t in current if t[0] in (1, 2)} if current else set() # which view should this be??? subfields = descr['views']['edition']['fields'] + # TODO: simplistic, unlikely to work if e.g. there's a 5 inbetween other commands for command in value: if command[0] in (0, 1): - c.pop(command[1], None) # remove record from currents + c.discard(command[1]) v.append((command[0], command[1], { k: self._cleanup_onchange( subfields[k], v, None @@ -1460,9 +1485,11 @@ class Form(object): if k in subfields })) elif command[0] == 2: + c.discard(command[1]) v.append((2, command[1], False)) elif command[0] == 4: - v.append((1, command[1], c.pop(command[1], {}))) + c.discard(command[1]) + v.append((1, command[1], {})) elif command[0] == 5: v = [] # explicitly mark all non-relinked (or modified) records as deleted @@ -1528,9 +1555,16 @@ class O2MForm(Form): if self._index is None: commands.append((0, 0, values)) else: - (c, _, vs) = commands[proxy._command_index(self._index)] - assert c in (0, 1) - vs.update(values) + index = proxy._command_index(self._index) + (c, id_, vs) = commands[index] + if c == 0: + vs.update(values) + elif c == 1: + vs = UpdateDict(vs) + vs.update(values) + commands[index] = (1, id_, vs) + else: + raise AssertionError("Expected command type 0 or 1, found %s" % c) # FIXME: should be called when performing on change => value needs to be serialised into parent every time? proxy._parent._perform_onchange([proxy._field]) @@ -1539,20 +1573,31 @@ class O2MForm(Form): """ Validates values and returns only fields modified since load/save """ - values = {} + values = UpdateDict(self._values) + values._changed.update(self._changed) + for f in self._view['fields']: - v = self._values[f] if self._get_modifier(f, 'required'): - assert v is not False, "{} is a required field".format(f) + assert self._values[f] is not False, "{} is a required field".format(f) - # skip unmodified fields - if f not in self._changed: - continue - # if self._get_modifier(f, 'readonly'): - # continue - values[f] = v return values +class UpdateDict(dict): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._changed = set() + + def changed_items(self): + return ( + (k, v) for k, v in self.items() + if k in self._changed + ) + + def update(self, *args, **kw): + super().update(*args, **kw) + if args and isinstance(args[0], UpdateDict): + self._changed.update(args[0]._changed) + class X2MProxy(object): _parent = None _field = None @@ -1754,6 +1799,13 @@ def record_to_values(fields, record): r[f] = v return r +def _get_node(view, f, *arg): + """ Find etree node for the field ``f`` in the view's arch + """ + return next(( + n for n in view['tree'].iter('field') + if n.get('name') == f + ), *arg) def tagged(*tags): """ diff --git a/odoo/tools/date_utils.py b/odoo/tools/date_utils.py index 26bfdabd835..702913ff04e 100644 --- a/odoo/tools/date_utils.py +++ b/odoo/tools/date_utils.py @@ -73,6 +73,10 @@ def get_fiscal_year(date, day=31, month=12): date_from = date_to + relativedelta(days=1) max_day = calendar.monthrange(date_to.year + 1, date_to.month)[1] date_to = type(date)(date.year + 1, month, min(day, max_day)) + + # Force at 29 February instead of 28 in case of leap year. + if date_to.month == 2 and date_to.day == 28 and max_day == 29: + date_to += relativedelta(days=1) return date_from, date_to |
