[MERGE] forward port branch saas-12.3 up to 120359bf0c

This commit is contained in:
Christophe Simonis
2019-04-12 13:37:44 +02:00
239 changed files with 5081 additions and 1967 deletions
+1 -1
View File
@@ -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',
+10
View File
@@ -670,6 +670,16 @@ msgstr ""
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)"
+1 -1
View File
@@ -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
+7 -8
View File
@@ -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
@@ -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,))
+24 -10
View File
@@ -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):
@@ -1032,6 +1036,10 @@ class AccountInvoice(models.Model):
raise UserError(_('Invoice must be paid in order to set it to register payment.'))
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()
@@ -1110,12 +1118,18 @@ class AccountInvoice(models.Model):
tax_grouped[key][field] += round_curr(val.get(field) or 0)
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
+19 -3
View File
@@ -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()
+244 -295
View File
@@ -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"
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.')
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.")
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",
# 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', '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")
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.multi
def action_register_payment(self):
active_ids = self.env.context.get('active_ids')
if not active_ids:
return ''
@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.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):
@@ -569,10 +418,6 @@ class account_payment(models.Model):
'domain': [('id', 'in', [x.id for x in self.reconciled_invoice_ids])],
}
@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.
@@ -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
@@ -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 = {}
+26
View File
@@ -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)
+10 -12
View File
@@ -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>
@@ -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);
});
@@ -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',
@@ -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) {
@@ -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 &amp;&amp; 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"
@@ -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},
+42 -165
View File
@@ -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)
+124 -1
View File
@@ -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)
@@ -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 &amp; 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 &amp; 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"/>
@@ -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>
+2 -1
View File
@@ -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"/>
+80 -157
View File
@@ -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>
<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')]}"/>
</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="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="show_partner_bank_account" invisible="1"/>
<field name="payment_method_code" invisible="1"/>
<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_date" attrs="{'readonly': [('state', '!=', 'draft')]}"/>
<field name="communication" attrs="{'invisible': [('state', '!=', 'draft'), ('communication', '=', False)], 'readonly': [('state', '!=', 'draft')]}"/>
<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), ('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" 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 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>
+2 -2
View File
@@ -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"/>
+16 -19
View File
@@ -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>
@@ -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>
+1
View File
@@ -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
@@ -9,20 +9,24 @@ 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})
invoice.message_post(attachment_ids=[attachment.id])
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.
@@ -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:
@@ -6,18 +6,13 @@
<field name="model">account.invoice.import.wizard</field>
<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>
@@ -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')
@@ -0,0 +1 @@
from . import test_print_check
@@ -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')
@@ -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>
+1 -1
View File
@@ -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'],
+115 -53
View File
@@ -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))
@@ -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 ''"/>
@@ -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,
@@ -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
@@ -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):
+2 -2
View File
@@ -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;
}
}
}
@@ -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({
+1 -1
View File
@@ -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):
+4 -1
View File
@@ -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.",
)
+1 -1
View File
@@ -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
@@ -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')
@@ -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"/>
+1 -1
View File
@@ -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:
+4 -1
View File
@@ -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):
+5 -1
View File
@@ -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
+1 -2
View File
@@ -1,2 +1 @@
from . import driver
from . import loading
from . import driver
+362 -393
View File
@@ -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__)
#----------------------------------------------------------
# Helper
#----------------------------------------------------------
def get_mac_address():
try:
return netifaces.ifaddresses('eth0')[netifaces.AF_LINK][0]['addr']
except:
return netifaces.ifaddresses('wlan0')[netifaces.AF_LINK][0]['addr']
def get_ip():
try:
return netifaces.ifaddresses('eth0')[netifaces.AF_INET][0]['addr']
except:
return netifaces.ifaddresses('wlan0')[netifaces.AF_INET][0]['addr']
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_odoo_server_url():
return read_file_first_line('odoo-remote-server.conf')
def get_token():
return read_file_first_line('token')
def get_version():
return '19_04'
#----------------------------------------------------------
# Controllers
#----------------------------------------------------------
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/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/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/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'
@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()
#----------------------------------------------------------
# Driver common interface
# Drivers
#----------------------------------------------------------
class Driver(Thread):
ping_value = ""
def supported(self):
pass
drivers = []
bt_devices = {}
iot_devices = {}
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):
class DriverMetaClass(type):
def __new__(cls, clsname, bases, attrs):
newclass = super(UsbMetaClass, cls).__new__(cls, clsname, bases, attrs)
usbdrivers.append(newclass)
newclass = super(DriverMetaClass, cls).__new__(cls, clsname, bases, attrs)
drivers.append(newclass)
return newclass
class USBDriver(Driver,metaclass=UsbMetaClass):
def __init__(self, dev):
super(USBDriver, self).__init__()
class Driver(Thread, metaclass=DriverMetaClass):
"""
Hook to register the driver into the drivers list
"""
connection_type = ""
def __init__(self, device):
super(Driver, self).__init__()
self.dev = device
self.data = {'value': ''}
self.gatt_device = False
@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_message(self):
return ''
def action(self, data):
"""
On specific driver override this method to make a action with device (take picture, printing,...)
"""
raise NotImplementedError()
def disconnect(self):
del iot_devices[self.device_identifier]
#----------------------------------------------------------
# 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.value = ""
self.connection_type = connection_type
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 get_connection(self):
return 'direct'
def value(self):
return self.value
event_manager = EventManager()
#----------------------------------------------------------
# Bluetooth
# Manager
#----------------------------------------------------------
class GattBtManager(gatt.DeviceManager):
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
def run(self):
"""
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:
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)
d = driverclass(device = updated_devices[path].dev)
d.daemon = True
d.start()
iot_devices[path] = d
self.send_alldevices()
break
time.sleep(3)
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)
if path not in bt_devices:
device.manager = self
iot_device = IoTDevice(device, 'bluetooth')
bt_devices[path] = iot_device
class BtManager(Thread):
gatt_manager = False
def run(self):
dm = GattBtManager(adapter_name='hci0')
self.gatt_manager = dm
for device in [device_con for device_con in dm.devices() if device_con.is_connected()]:
device.disconnect()
dm.start_discovery()
dm.run()
#----------------------------------------------------------
# Bluetooth drivers
#----------------------------------------------------------
btdrivers = []
class BtMetaClass(type):
def __new__(cls, clsname, bases, attrs):
newclass = super(BtMetaClass, cls).__new__(cls, clsname, bases, attrs)
btdrivers.append(newclass)
return newclass
class BtDriver(Driver, metaclass=BtMetaClass):
def __init__(self, device, manager):
super(BtDriver, self).__init__()
self.dev = device
self.manager = manager
self.value = ''
self.gatt_device = False
def disconnect(self):
path = "bt_%s" % (self.dev.mac_address,)
del drivers[path]
def get_name(self):
return self.dev.alias()
def value(self):
return self.value
def action(self, action):
pass
def get_connection(self):
return 'bluetooth'
def connect(self):
pass
class USBDeviceManager(Thread):
devices = {}
def run(self):
first_time = True
send_iot_box_device(False)
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():
_logger.info('For device %s will be driven', path)
drivers[path] = d
# launch thread
d.daemon = True
d.start()
sendJSON = True
if sendJSON or first_time:
send_iot_box_device(send_printer = first_time)
first_time = False
time.sleep(3)
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()
-34
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 546 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 542 B

+60 -98
View File
@@ -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',
@@ -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>
@@ -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>
+3 -3
View File
@@ -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
""",
+4
View File
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models
+19
View File
@@ -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,
}
+3
View File
@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import account_invoice
@@ -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
@@ -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>
+3
View File
@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import test_ubl
+33
View File
@@ -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)
@@ -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', '&gt;', 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">
+4 -4
View File
@@ -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">
+2 -2
View File
@@ -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
1 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
2 tax_in_e 仮受消費税(外) 仮受消費税(外) l10n_jp1 sale percent 8 FALSE 1 FALSE A21807 A21809 A21807 A21809 tag_jp_03,tag_jp_12 tax_group_8
3 tax_in_i 仮受消費税(内) 仮受消費税(内) l10n_jp1 sale percent 8 TRUE 1 FALSE A21807 A21809 A21807 A21809 tag_jp_03,tag_jp_12 tax_group_8
4 tax_in_x 輸出免税 輸出免税 l10n_jp1 sale percent FALSE 1 FALSE tag_jp_04,tag_jp_13 tax_group_0
5 tax_in_o 非課税販売 非課税販売 l10n_jp1 sale percent FALSE 1 FALSE tag_jp_09,tag_jp_14 tax_group_0
6 tax_out_e 仮払消費税(外) 仮払消費税(外) l10n_jp1 purchase percent 8 FALSE 1 FALSE A11807 A11807 tag_jp_07,tag_jp_16 tax_group_8
+25 -11
View File
@@ -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'))
# 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
# 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_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'), ('name', 'in', list(in_xml_id_names))])
in_xml_ids = in_xml_ids.sorted(key=lambda x: in_xml_id_names[x.name])
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}
in_ids = self.env[model+'.template'].browse(in_xml_ids.mapped('res_id'))
return (in_ids, out_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"""
+4
View File
@@ -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>
+1 -1
View File
@@ -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>
+4 -3
View File
@@ -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
+10 -8
View File
@@ -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)
+2 -2
View File
@@ -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):
+1 -1
View File
@@ -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):
+4
View File
@@ -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();
},
/**
+2 -3
View File
@@ -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('/');
+17 -4
View File
@@ -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;
}
}
}
@@ -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%;
}
+10 -10
View File
@@ -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&amp;signature=#{widget.activeAttachment.checksum}&amp;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&amp;signature=#{widget.activeAttachment.checksum}&amp;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>
@@ -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();
});
});
});
@@ -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'
@@ -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
+39 -7
View File
@@ -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)
+10 -3
View File
@@ -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):
+5 -13
View File
@@ -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):
-5
View File
@@ -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'))
+199 -40
View File
@@ -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")
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.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
@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()
# Set a qty producing
# One a piece is produced, you can launch the next work order
self._start_nextworkorder()
# 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
+1
View File
@@ -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')
+5
View File
@@ -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')
+9 -4
View File
@@ -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()
+7 -10
View File
@@ -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
+6 -6
View File
@@ -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>
@@ -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
-6
View File
@@ -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>
+12 -5
View File
@@ -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>
@@ -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">
+5
View File
@@ -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>
@@ -30,7 +30,7 @@
<tree editable="top" delete="0" decoration-danger="(qty_to_consume &lt; 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"/>
+3
View File
@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models
+19
View File
@@ -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,
}

Some files were not shown because too many files have changed in this diff Show More