[MERGE] forward port branch saas-12.3 up to 120359bf0c
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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)
|
||||
@@ -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 && 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},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 & Print" type="object" attrs="{'invisible':['|',('sent','=',True), ('state', 'not in', ('open','in_payment','paid'))]}" class="oe_highlight" groups="base.group_user"/>
|
||||
<button name="action_invoice_sent" string="Send & Print" type="object" attrs="{'invisible':['|',('sent','=',False), ('state', 'not in', ('open','in_payment','paid'))]}" groups="base.group_user"/>
|
||||
<button name="%(action_account_invoice_payment)d" id="account_invoice_payment_btn" type="action"
|
||||
<button name="action_register_payment" id="account_invoice_payment_btn" type="object"
|
||||
attrs="{'invisible': [('state', '!=', 'open')]}"
|
||||
string="Register Payment" groups="account.group_account_invoice" class="oe_highlight"/>
|
||||
<button name="action_invoice_open" type="object" states="draft" string="Validate" class="oe_highlight o_invoice_validate" groups="account.group_account_invoice"/>
|
||||
@@ -460,7 +460,7 @@
|
||||
widget="res_partner_many2one"
|
||||
context="{'search_default_customer':1, 'show_address': 1, 'default_is_company': True, 'show_vat': True}"
|
||||
options='{"always_reload": True, "no_quick_create": True}'
|
||||
domain="[('customer', '=', True)]" required="1"/>
|
||||
domain="[('customer', '=', True)]"/>
|
||||
<field name="payment_term_id"/>
|
||||
<field name="cash_rounding_id" groups="account.group_cash_rounding"/>
|
||||
</group>
|
||||
@@ -632,6 +632,7 @@
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
<div class="o_attachment_preview"/>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids" widget="mail_followers" groups="base.group_user"/>
|
||||
<field name="activity_ids" widget="mail_activity"/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
+12
-6
@@ -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:
|
||||
+1
-6
@@ -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,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'],
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.",
|
||||
)
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 +1 @@
|
||||
from . import driver
|
||||
from . import loading
|
||||
from . import driver
|
||||
@@ -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()
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
""",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import models
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import test_ubl
|
||||
@@ -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', '>', 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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,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"""
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
/**
|
||||
|
||||
@@ -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('/');
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -38,9 +38,9 @@
|
||||
<t t-set="model" t-value="widget.modelName"/>
|
||||
<div class="o_viewer-header">
|
||||
<span class="o_image_caption">
|
||||
<i class="fa fa-picture-o mr8" t-if="widget.activeAttachment.type == 'image'" role="img" aria-label="Image" title="Image"/>
|
||||
<i class="fa fa-file-text mr8" t-if="widget.activeAttachment.type == 'application/pdf'" role="img" aria-label="PDF file" title="PDF file"/>
|
||||
<i class="fa fa-video-camera mr8" t-if="widget.activeAttachment.type == 'video'" role="img" aria-label="Video" title="Video"/>
|
||||
<i class="fa fa-picture-o mr8" t-if="widget.activeAttachment.fileType == 'image'" role="img" aria-label="Image" title="Image"/>
|
||||
<i class="fa fa-file-text mr8" t-if="widget.activeAttachment.fileType == 'application/pdf'" role="img" aria-label="PDF file" title="PDF file"/>
|
||||
<i class="fa fa-video-camera mr8" t-if="widget.activeAttachment.fileType == 'video'" role="img" aria-label="Video" title="Video"/>
|
||||
<t t-esc="widget.activeAttachment.name"/>
|
||||
<a role="button" href="#" class="o_download_btn ml8 small" data-toggle="tooltip" data-placement="right" title="Download"><i class="fa fa-fw fa-download" role="img" aria-label="Download"/></a>
|
||||
</span>
|
||||
@@ -48,19 +48,19 @@
|
||||
</div>
|
||||
<div class="o_viewer_img_wrapper">
|
||||
<div class="o_viewer_zoomer">
|
||||
<div t-if="widget.activeAttachment.type == 'image'" class="o_loading_img text-center">
|
||||
<div t-if="widget.activeAttachment.fileType == 'image'" class="o_loading_img text-center">
|
||||
<i class="fa fa-circle-o-notch fa-spin text-gray-light fa-3x fa-fw" role="img" aria-label="Loading" title="Loading"/>
|
||||
</div>
|
||||
<img t-if="widget.activeAttachment.type === 'image'" class="o_viewer_img" t-attf-src="/web/image/#{widget.activeAttachment.id}?unique=1&signature=#{widget.activeAttachment.checksum}&model=#{model}" alt="Viewer"/>
|
||||
<iframe t-if="widget.activeAttachment.type == 'application/pdf'" class="mt32 o_viewer_pdf" t-attf-src="/web/static/lib/pdfjs/web/viewer.html?file=/web/content/#{widget.activeAttachment.id}?model%3D#{model}" />
|
||||
<iframe t-if="(widget.activeAttachment.type || '').indexOf('text') !== -1" class="mt32 o_viewer_text" t-attf-src="/web/content/#{widget.activeAttachment.id}?model%3D#{model}" />
|
||||
<iframe t-if="widget.activeAttachment.type == 'youtu'" class="mt32 o_viewer_text" allow="autoplay; encrypted-media" width="560" height="315" t-attf-src="https://www.youtube.com/embed/#{widget.activeAttachment.youtube}"/>
|
||||
<video t-if="widget.activeAttachment.type == 'video'" class="o_viewer_video" controls="controls">
|
||||
<img t-if="widget.activeAttachment.fileType === 'image'" class="o_viewer_img" t-attf-src="/web/image/#{widget.activeAttachment.id}?unique=1&signature=#{widget.activeAttachment.checksum}&model=#{model}" alt="Viewer"/>
|
||||
<iframe t-if="widget.activeAttachment.fileType == 'application/pdf'" class="mt32 o_viewer_pdf" t-attf-src="/web/static/lib/pdfjs/web/viewer.html?file=/web/content/#{widget.activeAttachment.id}?model%3D#{model}" />
|
||||
<iframe t-if="(widget.activeAttachment.fileType || '').indexOf('text') !== -1" class="mt32 o_viewer_text" t-attf-src="/web/content/#{widget.activeAttachment.id}?model%3D#{model}" />
|
||||
<iframe t-if="widget.activeAttachment.fileType == 'youtu'" class="mt32 o_viewer_text" allow="autoplay; encrypted-media" width="560" height="315" t-attf-src="https://www.youtube.com/embed/#{widget.activeAttachment.youtube}"/>
|
||||
<video t-if="widget.activeAttachment.fileType == 'video'" class="o_viewer_video" controls="controls">
|
||||
<source t-attf-src="/web/image/#{widget.activeAttachment.id}?model=#{model}" t-att-data-type="widget.activeAttachment.mimetype"/>
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
<div t-if="widget.activeAttachment.type == 'image'" class="o_viewer_toolbar btn-toolbar" role="toolbar">
|
||||
<div t-if="widget.activeAttachment.fileType == 'image'" class="o_viewer_toolbar btn-toolbar" role="toolbar">
|
||||
<div class="btn-group ml0" role="group">
|
||||
<a role="button" href="#" class="o_viewer_toolbar_btn btn o_zoom_in" data-toggle="tooltip" title="Zoom In"><i class="fa fa-fw fa-plus" role="img" aria-label="Zoom In"/></a>
|
||||
<a role="button" href="#" class="o_viewer_toolbar_btn btn o_zoom_reset disabled" data-toggle="tooltip" title="Reset Zoom"><i class="fa fa-fw fa-search" role="img" aria-label="Reset Zoom"/></a>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 < 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"/>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from . import models
|
||||
@@ -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
Reference in New Issue
Block a user