TLDR:
* invoices are implemented using computed methods instead of onchange
* the synchronization only happens when switching tabs in the Form view
to improve perfs.
_______________________________________________________________________
The whole engine of the synchronization of Invoices to the Journal
Entries has been refactored
* by using computed fields instead of onchange functions
* by synchronizing only from invoice to journal entry in `create` and
`write`
* by saving when switching tabs on the Invoice form, to synchronize
before showing the values
This comes with numerous advantages:
* no need to call the onchange methods manually
* no need to use the Form emulator to build invoices (i.e. EDI, OCR,
intercompany, ...)
* the performance for invoices with many lines improves drastically, going
from 2 minutes to 4 seconds to create an invoice with 500 lines
* the model is more declarative, we can now see how the values are computed
instead of having the values being copied from various places.
* remove the hack in `onchange` that disabled the recursivity of it,
which was unexpected and needed to be managed manually in all the
onchange methods
This means that:
* Some fields need to be exclusively computed on journal entries values
or invoice values, more specifically the Tax Summary widget.
It is now
- computed from entry lines, when opening the view
- computed from invoice lines when changing those, because the tax lines
will need to be recomputed anyways, erasing previously set values
- set with an inverse function when saving; after the sync has been done
* Some possible operations previously possible have been dropped.
(i.e. look at the removed test test_in_invoice_line_onchange_accounting_fields_1)
This is because such a behavior was undefined (how is changing the balance going
to affect the unit price? How is the amount currency going to affect it?)
_______________________________________________________________________
Implementation Details
----------------------
The "dynamic lines", meaning the payment terms and the tax lines are now
only created in the `create` and `write` functions.
In order to reduce code duplication, it has been implemented using
context managers used in both `account.move` and `account.move.line`
These context managers help comparing the values before/after, acting
like a local `onchange`, but getting benefit from the dirty flags from
the `compute` dependences.
This is relying on computed fields on the move (`needed_terms`) and on
the lines (`compute_all_tax`) which contain the values needed for the
related move.
Depending on the needed values and the existing values (`term_key` and
`tax_key`, respectively) the context manager will determine what needs
to be created/updated/deleted.
Some related changes are to produce a `dict` instead of a `str` for the
`tax_totals` (previously `tax_totals_json`) fields, by simplicity to
reduce the complexity of IO, and simplicity of debugging, because the
logic of the field needed to change (cannot be computed at the same time
anymore since it needed the lines to be synced)
By simplicity, and also because it makes more sense, some boolean fields
have been merged into `display_type`:
* `is_rounding_line`
* `exclude_from_invoice_tab`
* `is_anglo_saxon_line`
The `price_unit`, `quantity` and other "invoice fields" are now not set
anymore on lines that are not product lines since it didn't make any
sense to have it.
Performances
------------
You have to keep in mind that a simple `create` didn't compute a lot of
fields, for instance not taxes were set, no payment terms,...
Now it does.
```python
import random
from timeit import timeit
from odoo import Command
domain = [('company_id', 'in', (False, self.env.company.id))]
products = self.env['product.product'].search(domain).ids
partners = self.env['res.partner'].search(domain).ids
taxes = self.env['account.tax'].search(domain).ids
def create(nmove, nline):
self.env['account.move'].create([
{
'move_type': 'out_invoice',
'partner_id': random.choice(partners),
'invoice_line_ids': [
Command.create({
'name': f'line{i}',
'product_id': random.choice(products),
'tax_ids': [Command.set([random.choice(taxes)])],
})
for i in range(nline)
]
}
for j in range(nmove)
])
# After | Before
print(timeit("create(1, 1)", globals=globals(), number=1)) # 0.11 | 0.09
print(timeit("create(100, 1)", globals=globals(), number=1)) # 2.76 | 2.50
print(timeit("create(500, 1)", globals=globals(), number=1)) # 14.56 | 12.34
print(timeit("create(1, 100)", globals=globals(), number=1)) # 1.03 | 5.52
print(timeit("create(1, 500)", globals=globals(), number=1)) # 3.99 | 125.02
print(timeit("create(50, 50)", globals=globals(), number=1)) # 19.44 | 79.55
```
Another metric that can be used is running the test suite with
`--test-tags=/account` (only `account` installed)
* before: 404s, 267127 queries (366 tests)
* after: 318s, 232125 queries (362 tests)
Why this commit title?
----------------------
Someone told me that this was the perfect way of naming your commits.
c04065abd8
task-2711317
closes odoo/odoo#96134
Related: odoo/upgrade#3715
Related: odoo/enterprise#29758
Signed-off-by: Laurent Smet <las@odoo.com>
301 lines
16 KiB
Python
301 lines
16 KiB
Python
# -*- encoding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
import base64
|
|
import re
|
|
from odoo import api, fields, models, _
|
|
from odoo.exceptions import UserError, ValidationError
|
|
from odoo.tools import float_round
|
|
|
|
FK_HEAD_LIST = ['FK', 'KD_JENIS_TRANSAKSI', 'FG_PENGGANTI', 'NOMOR_FAKTUR', 'MASA_PAJAK', 'TAHUN_PAJAK', 'TANGGAL_FAKTUR', 'NPWP', 'NAMA', 'ALAMAT_LENGKAP', 'JUMLAH_DPP', 'JUMLAH_PPN', 'JUMLAH_PPNBM', 'ID_KETERANGAN_TAMBAHAN', 'FG_UANG_MUKA', 'UANG_MUKA_DPP', 'UANG_MUKA_PPN', 'UANG_MUKA_PPNBM', 'REFERENSI', 'KODE_DOKUMEN_PENDUKUNG']
|
|
|
|
LT_HEAD_LIST = ['LT', 'NPWP', 'NAMA', 'JALAN', 'BLOK', 'NOMOR', 'RT', 'RW', 'KECAMATAN', 'KELURAHAN', 'KABUPATEN', 'PROPINSI', 'KODE_POS', 'NOMOR_TELEPON']
|
|
|
|
OF_HEAD_LIST = ['OF', 'KODE_OBJEK', 'NAMA', 'HARGA_SATUAN', 'JUMLAH_BARANG', 'HARGA_TOTAL', 'DISKON', 'DPP', 'PPN', 'TARIF_PPNBM', 'PPNBM']
|
|
|
|
|
|
def _csv_row(data, delimiter=',', quote='"'):
|
|
return quote + (quote + delimiter + quote).join([str(x).replace(quote, '\\' + quote) for x in data]) + quote + '\n'
|
|
|
|
|
|
class AccountMove(models.Model):
|
|
_inherit = "account.move"
|
|
|
|
l10n_id_tax_number = fields.Char(string="Tax Number", copy=False)
|
|
l10n_id_replace_invoice_id = fields.Many2one('account.move', string="Replace Invoice", domain="['|', '&', '&', ('state', '=', 'posted'), ('partner_id', '=', partner_id), ('reversal_move_id', '!=', False), ('state', '=', 'cancel')]", copy=False)
|
|
l10n_id_attachment_id = fields.Many2one('ir.attachment', readonly=True, copy=False)
|
|
l10n_id_csv_created = fields.Boolean('CSV Created', compute='_compute_csv_created', copy=False)
|
|
l10n_id_kode_transaksi = fields.Selection([
|
|
('01', '01 Kepada Pihak yang Bukan Pemungut PPN (Customer Biasa)'),
|
|
('02', '02 Kepada Pemungut Bendaharawan (Dinas Kepemerintahan)'),
|
|
('03', '03 Kepada Pemungut Selain Bendaharawan (BUMN)'),
|
|
('04', '04 DPP Nilai Lain (PPN 1%)'),
|
|
('05', '05 Besaran Tertentu'),
|
|
('06', '06 Penyerahan Lainnya (Turis Asing)'),
|
|
('07', '07 Penyerahan yang PPN-nya Tidak Dipungut (Kawasan Ekonomi Khusus/ Batam)'),
|
|
('08', '08 Penyerahan yang PPN-nya Dibebaskan (Impor Barang Tertentu)'),
|
|
('09', '09 Penyerahan Aktiva ( Pasal 16D UU PPN )'),
|
|
], string='Kode Transaksi', help='Dua digit pertama nomor pajak',
|
|
readonly=True, states={'draft': [('readonly', False)]}, copy=False)
|
|
l10n_id_need_kode_transaksi = fields.Boolean(compute='_compute_need_kode_transaksi')
|
|
|
|
@api.onchange('partner_id')
|
|
def _onchange_partner_id(self):
|
|
self.l10n_id_kode_transaksi = self.partner_id.l10n_id_kode_transaksi
|
|
return super(AccountMove, self)._onchange_partner_id()
|
|
|
|
@api.onchange('l10n_id_tax_number')
|
|
def _onchange_l10n_id_tax_number(self):
|
|
for record in self:
|
|
if record.l10n_id_tax_number and record.move_type not in self.get_purchase_types():
|
|
raise UserError(_("You can only change the number manually for a Vendor Bills and Credit Notes"))
|
|
|
|
@api.depends('l10n_id_attachment_id')
|
|
def _compute_csv_created(self):
|
|
for record in self:
|
|
record.l10n_id_csv_created = bool(record.l10n_id_attachment_id)
|
|
|
|
@api.depends('partner_id')
|
|
def _compute_need_kode_transaksi(self):
|
|
for move in self:
|
|
move.l10n_id_need_kode_transaksi = move.partner_id.l10n_id_pkp and not move.l10n_id_tax_number and move.move_type == 'out_invoice' and move.country_code == 'ID'
|
|
|
|
@api.constrains('l10n_id_kode_transaksi', 'line_ids')
|
|
def _constraint_kode_ppn(self):
|
|
ppn_tag = self.env.ref('l10n_id.ppn_tag')
|
|
for move in self.filtered(lambda m: m.l10n_id_kode_transaksi != '08'):
|
|
if any(ppn_tag.id in line.tax_tag_ids.ids for line in move.line_ids if line.display_type == 'product') \
|
|
and any(ppn_tag.id not in line.tax_tag_ids.ids for line in move.line_ids if line.display_type == 'product'):
|
|
raise UserError(_('Cannot mix VAT subject and Non-VAT subject items in the same invoice with this kode transaksi.'))
|
|
for move in self.filtered(lambda m: m.l10n_id_kode_transaksi == '08'):
|
|
if any(ppn_tag.id in line.tax_tag_ids.ids for line in move.line_ids if line.display_type == 'product'):
|
|
raise UserError('Kode transaksi 08 is only for non VAT subject items.')
|
|
|
|
@api.constrains('l10n_id_tax_number')
|
|
def _constrains_l10n_id_tax_number(self):
|
|
for record in self.filtered('l10n_id_tax_number'):
|
|
if record.l10n_id_tax_number != re.sub(r'\D', '', record.l10n_id_tax_number):
|
|
record.l10n_id_tax_number = re.sub(r'\D', '', record.l10n_id_tax_number)
|
|
if len(record.l10n_id_tax_number) != 16:
|
|
raise UserError(_('A tax number should have 16 digits'))
|
|
elif record.l10n_id_tax_number[:2] not in dict(self._fields['l10n_id_kode_transaksi'].selection).keys():
|
|
raise UserError(_('A tax number must begin by a valid Kode Transaksi'))
|
|
elif record.l10n_id_tax_number[2] not in ('0', '1'):
|
|
raise UserError(_('The third digit of a tax number must be 0 or 1'))
|
|
|
|
def _post(self, soft=True):
|
|
"""Set E-Faktur number after validation."""
|
|
for move in self:
|
|
if move.l10n_id_need_kode_transaksi:
|
|
if not move.l10n_id_kode_transaksi:
|
|
raise ValidationError(_('You need to put a Kode Transaksi for this partner.'))
|
|
if move.l10n_id_replace_invoice_id.l10n_id_tax_number:
|
|
if not move.l10n_id_replace_invoice_id.l10n_id_attachment_id:
|
|
raise ValidationError(_('Replacement invoice only for invoices on which the e-Faktur is generated. '))
|
|
rep_efaktur_str = move.l10n_id_replace_invoice_id.l10n_id_tax_number
|
|
move.l10n_id_tax_number = '%s1%s' % (move.l10n_id_kode_transaksi, rep_efaktur_str[3:])
|
|
else:
|
|
efaktur = self.env['l10n_id_efaktur.efaktur.range'].pop_number(move.company_id.id)
|
|
if not efaktur:
|
|
raise ValidationError(_('There is no Efaktur number available. Please configure the range you get from the government in the e-Faktur menu. '))
|
|
move.l10n_id_tax_number = '%s0%013d' % (str(move.l10n_id_kode_transaksi), efaktur)
|
|
return super()._post(soft)
|
|
|
|
def reset_efaktur(self):
|
|
"""Reset E-Faktur, so it can be use for other invoice."""
|
|
for move in self:
|
|
if move.l10n_id_attachment_id:
|
|
raise UserError(_('You have already generated the tax report for this document: %s', move.name))
|
|
self.env['l10n_id_efaktur.efaktur.range'].push_number(move.company_id.id, move.l10n_id_tax_number[3:])
|
|
move.message_post(
|
|
body='e-Faktur Reset: %s ' % (move.l10n_id_tax_number),
|
|
subject="Reset Efaktur")
|
|
move.l10n_id_tax_number = False
|
|
return True
|
|
|
|
def download_csv(self):
|
|
action = {
|
|
'type': 'ir.actions.act_url',
|
|
'url': "web/content/?model=ir.attachment&id=" + str(self.l10n_id_attachment_id.id) + "&filename_field=name&field=datas&download=true&name=" + self.l10n_id_attachment_id.name,
|
|
'target': 'self'
|
|
}
|
|
return action
|
|
|
|
def download_efaktur(self):
|
|
"""Collect the data and execute function _generate_efaktur."""
|
|
for record in self:
|
|
if record.state == 'draft':
|
|
raise ValidationError(_('Could not download E-faktur in draft state'))
|
|
|
|
if record.partner_id.l10n_id_pkp and not record.l10n_id_tax_number:
|
|
raise ValidationError(_('Connect %(move_number)s with E-faktur to download this report', move_number=record.name))
|
|
|
|
self._generate_efaktur(',')
|
|
return self.download_csv()
|
|
|
|
def _generate_efaktur_invoice(self, delimiter):
|
|
"""Generate E-Faktur for customer invoice."""
|
|
# Invoice of Customer
|
|
company_id = self.company_id
|
|
dp_product_id = self.env['ir.config_parameter'].sudo().get_param('sale.default_deposit_product_id')
|
|
|
|
output_head = '%s%s%s' % (
|
|
_csv_row(FK_HEAD_LIST, delimiter),
|
|
_csv_row(LT_HEAD_LIST, delimiter),
|
|
_csv_row(OF_HEAD_LIST, delimiter),
|
|
)
|
|
|
|
for move in self.filtered(lambda m: m.state == 'posted'):
|
|
eTax = move._prepare_etax()
|
|
|
|
nik = str(move.partner_id.l10n_id_nik) if not move.partner_id.vat else ''
|
|
|
|
if move.l10n_id_replace_invoice_id:
|
|
number_ref = str(move.l10n_id_replace_invoice_id.name) + " replaced by " + str(move.name) + " " + nik
|
|
else:
|
|
number_ref = str(move.name) + " " + nik
|
|
|
|
street = ', '.join([x for x in (move.partner_id.street, move.partner_id.street2) if x])
|
|
|
|
invoice_npwp = '000000000000000'
|
|
if move.partner_id.vat and len(move.partner_id.vat) >= 12:
|
|
invoice_npwp = move.partner_id.vat
|
|
elif (not move.partner_id.vat or len(move.partner_id.vat) < 12) and move.partner_id.l10n_id_nik:
|
|
invoice_npwp = move.partner_id.l10n_id_nik
|
|
invoice_npwp = invoice_npwp.replace('.', '').replace('-', '')
|
|
|
|
# Here all fields or columns based on eTax Invoice Third Party
|
|
eTax['KD_JENIS_TRANSAKSI'] = move.l10n_id_tax_number[0:2] or 0
|
|
eTax['FG_PENGGANTI'] = move.l10n_id_tax_number[2:3] or 0
|
|
eTax['NOMOR_FAKTUR'] = move.l10n_id_tax_number[3:] or 0
|
|
eTax['MASA_PAJAK'] = move.invoice_date.month
|
|
eTax['TAHUN_PAJAK'] = move.invoice_date.year
|
|
eTax['TANGGAL_FAKTUR'] = '{0}/{1}/{2}'.format(move.invoice_date.day, move.invoice_date.month, move.invoice_date.year)
|
|
eTax['NPWP'] = invoice_npwp
|
|
eTax['NAMA'] = move.partner_id.name if eTax['NPWP'] == '000000000000000' else move.partner_id.l10n_id_tax_name or move.partner_id.name
|
|
eTax['ALAMAT_LENGKAP'] = move.partner_id.contact_address.replace('\n', '') if eTax['NPWP'] == '000000000000000' else move.partner_id.l10n_id_tax_address or street
|
|
eTax['JUMLAH_DPP'] = int(float_round(move.amount_untaxed, 0)) # currency rounded to the unit
|
|
eTax['JUMLAH_PPN'] = int(float_round(move.amount_tax, 0))
|
|
eTax['ID_KETERANGAN_TAMBAHAN'] = '1' if move.l10n_id_kode_transaksi == '07' else ''
|
|
eTax['REFERENSI'] = number_ref
|
|
eTax['KODE_DOKUMEN_PENDUKUNG'] = '0'
|
|
|
|
lines = move.line_ids.filtered(lambda x: x.product_id.id == int(dp_product_id) and x.price_unit < 0 and x.display_type == 'product')
|
|
eTax['FG_UANG_MUKA'] = 0
|
|
eTax['UANG_MUKA_DPP'] = int(abs(sum(lines.mapped(lambda l: float_round(l.price_subtotal, 0)))))
|
|
eTax['UANG_MUKA_PPN'] = int(abs(sum(lines.mapped(lambda l: float_round(l.price_total - l.price_subtotal, 0)))))
|
|
|
|
fk_values_list = ['FK'] + [eTax[f] for f in FK_HEAD_LIST[1:]]
|
|
|
|
# HOW TO ADD 2 line to 1 line for free product
|
|
free, sales = [], []
|
|
|
|
for line in move.line_ids.filtered(lambda l: l.display_type == 'product'):
|
|
# *invoice_line_unit_price is price unit use for harga_satuan's column
|
|
# *invoice_line_quantity is quantity use for jumlah_barang's column
|
|
# *invoice_line_total_price is bruto price use for harga_total's column
|
|
# *invoice_line_discount_m2m is discount price use for diskon's column
|
|
# *line.price_subtotal is subtotal price use for dpp's column
|
|
# *tax_line or free_tax_line is tax price use for ppn's column
|
|
free_tax_line = tax_line = bruto_total = total_discount = 0.0
|
|
|
|
for tax in line.tax_ids:
|
|
if tax.amount > 0:
|
|
tax_line += line.price_subtotal * (tax.amount / 100.0)
|
|
|
|
discount = 1 - (line.discount / 100)
|
|
# guarantees price to be tax-excluded
|
|
invoice_line_total_price = line.price_subtotal / discount if discount else 0
|
|
invoice_line_unit_price = invoice_line_total_price / line.quantity if line.quantity else 0
|
|
|
|
line_dict = {
|
|
'KODE_OBJEK': line.product_id.default_code or '',
|
|
'NAMA': line.product_id.name or '',
|
|
'HARGA_SATUAN': int(float_round(invoice_line_unit_price, 0)),
|
|
'JUMLAH_BARANG': line.quantity,
|
|
'HARGA_TOTAL': int(float_round(invoice_line_total_price, 0)),
|
|
'DPP': int(float_round(line.price_subtotal, 0)),
|
|
'product_id': line.product_id.id,
|
|
}
|
|
|
|
if line.price_subtotal < 0:
|
|
for tax in line.tax_ids:
|
|
free_tax_line += (line.price_subtotal * (tax.amount / 100.0)) * -1.0
|
|
|
|
line_dict.update({
|
|
'DISKON': int(float_round(invoice_line_total_price - line.price_subtotal, 0)),
|
|
'PPN': int(float_round(free_tax_line, 0)),
|
|
})
|
|
free.append(line_dict)
|
|
elif line.price_subtotal != 0.0:
|
|
invoice_line_discount_m2m = invoice_line_total_price - line.price_subtotal
|
|
|
|
line_dict.update({
|
|
'DISKON': int(float_round(invoice_line_discount_m2m, 0)),
|
|
'PPN': int(float_round(tax_line, 0)),
|
|
})
|
|
sales.append(line_dict)
|
|
|
|
sub_total_before_adjustment = sub_total_ppn_before_adjustment = 0.0
|
|
|
|
# We are finding the product that has affected
|
|
# by free product to adjustment the calculation
|
|
# of discount and subtotal.
|
|
# - the price total of free product will be
|
|
# included as a discount to related of product.
|
|
for sale in sales:
|
|
for f in free:
|
|
if f['product_id'] == sale['product_id']:
|
|
sale['DISKON'] = sale['DISKON'] - f['DISKON'] + f['PPN']
|
|
sale['DPP'] = sale['DPP'] + f['DPP']
|
|
|
|
tax_line = 0
|
|
|
|
for tax in line.tax_ids:
|
|
if tax.amount > 0:
|
|
tax_line += sale['DPP'] * (tax.amount / 100.0)
|
|
|
|
sale['PPN'] = int(float_round(tax_line, 0))
|
|
|
|
free.remove(f)
|
|
|
|
sub_total_before_adjustment += sale['DPP']
|
|
sub_total_ppn_before_adjustment += sale['PPN']
|
|
bruto_total += sale['DISKON']
|
|
total_discount += float_round(sale['DISKON'], 2)
|
|
|
|
output_head += _csv_row(fk_values_list, delimiter)
|
|
for sale in sales:
|
|
of_values_list = ['OF'] + [str(sale[f]) for f in OF_HEAD_LIST[1:-2]] + ['0', '0']
|
|
output_head += _csv_row(of_values_list, delimiter)
|
|
|
|
return output_head
|
|
|
|
def _prepare_etax(self):
|
|
# These values are never set
|
|
return {'JUMLAH_PPNBM': 0, 'UANG_MUKA_PPNBM': 0, 'JUMLAH_BARANG': 0, 'TARIF_PPNBM': 0, 'PPNBM': 0}
|
|
|
|
def _generate_efaktur(self, delimiter):
|
|
if self.filtered(lambda x: not x.l10n_id_kode_transaksi):
|
|
raise UserError(_('Some documents don\'t have a transaction code'))
|
|
if self.filtered(lambda x: x.move_type != 'out_invoice'):
|
|
raise UserError(_('Some documents are not Customer Invoices'))
|
|
|
|
output_head = self._generate_efaktur_invoice(delimiter)
|
|
my_utf8 = output_head.encode("utf-8")
|
|
out = base64.b64encode(my_utf8)
|
|
|
|
attachment = self.env['ir.attachment'].create({
|
|
'datas': out,
|
|
'name': 'efaktur_%s.csv' % (fields.Datetime.to_string(fields.Datetime.now()).replace(" ", "_")),
|
|
'type': 'binary',
|
|
})
|
|
|
|
for record in self:
|
|
record.message_post(attachment_ids=[attachment.id])
|
|
self.l10n_id_attachment_id = attachment.id
|
|
return {
|
|
'type': 'ir.actions.client',
|
|
'tag': 'reload',
|
|
}
|