Odoo18-Base/addons/l10n_id_efaktur/models/account_move.py
2025-03-10 11:12:23 +07:00

372 lines
20 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, RedirectWarning
from odoo.tools import float_round, float_repr
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, index='btree_not_null')
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 To the Parties that is not VAT Collector (Regular Customers)'),
('02', '02 To the Treasurer'),
('03', '03 To other VAT Collectors other than the Treasurer'),
('04', '04 Other Value of VAT Imposition Base'),
('05', '05 Specified Amount (Article 9A Paragraph (1) VAT Law)'),
('06', '06 to individuals holding foreign passports'),
('07', '07 Deliveries that the VAT is not Collected'),
('08', '08 Deliveries that the VAT is Exempted'),
('09', '09 Deliveries of Assets (Article 16D of VAT Law)'),
], string='Kode Transaksi', help='Dua digit pertama nomor pajak',
readonly=False, states={'posted': [('readonly', True)], 'cancel': [('readonly', True)]}, copy=False,
compute="_compute_kode_transaksi", store=True)
l10n_id_need_kode_transaksi = fields.Boolean(compute='_compute_need_kode_transaksi')
@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_kode_transaksi(self):
for move in self:
move.l10n_id_kode_transaksi = move.partner_id.commercial_partner_id.l10n_id_kode_transaksi
@api.depends('partner_id', 'line_ids.tax_ids')
def _compute_need_kode_transaksi(self):
for move in self:
# If there are no taxes at all on every line (0% taxes counts as having a tax) then we don't need a kode transaksi
move.l10n_id_need_kode_transaksi = (
move.partner_id.commercial_partner_id.l10n_id_pkp
and not move.l10n_id_tax_number
and move.move_type == 'out_invoice'
and move.country_code == 'ID'
and move.line_ids.tax_ids
)
@api.constrains('l10n_id_kode_transaksi', 'line_ids', 'partner_id')
def _constraint_kode_ppn(self):
ppn_tag = self.env.ref('l10n_id.ppn_tag')
for move in self.filtered(lambda m: m.l10n_id_need_kode_transaksi and 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_need_kode_transaksi and 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 not record.l10n_id_tax_number:
if not record.l10n_id_need_kode_transaksi:
raise ValidationError(_('E-faktur is not available for invoices without any taxes.'))
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
output_head = '%s%s%s' % (
_csv_row(FK_HEAD_LIST, delimiter),
_csv_row(LT_HEAD_LIST, delimiter),
_csv_row(OF_HEAD_LIST, delimiter),
)
idr = self.env.ref('base.IDR')
for move in self.filtered(lambda m: m.state == 'posted'):
eTax = move._prepare_etax()
commercial_partner = move.partner_id.commercial_partner_id
nik = str(commercial_partner.l10n_id_nik) if not commercial_partner.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
elif nik:
number_ref = str(move.name) + " " + nik
else:
number_ref = str(move.name)
street = ', '.join([x for x in (move.partner_id.street, move.partner_id.street2) if x])
invoice_npwp = ''
if commercial_partner.vat and len(commercial_partner.vat) >= 15:
invoice_npwp = commercial_partner.vat
elif commercial_partner.l10n_id_nik:
invoice_npwp = commercial_partner.l10n_id_nik
if not invoice_npwp:
action_error = {
'view_mode': 'form',
'res_model': 'res.partner',
'type': 'ir.actions.act_window',
'res_id': commercial_partner.id,
'views': [[self.env.ref('base.view_partner_form').id, 'form']],
}
msg = _("Please make sure that you've input the appropriate NPWP or NIK for the following customer")
raise RedirectWarning(msg, action_error, _("Edit Customer Information"))
invoice_npwp = invoice_npwp.replace('.', '').replace('-', '')
etax_name = commercial_partner.l10n_id_tax_name or move.partner_id.name
if invoice_npwp[:15] == '000000000000000' and commercial_partner.l10n_id_nik:
etax_name = "%s#NIK#NAMA#%s" % (commercial_partner.l10n_id_nik, etax_name)
# 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'] = etax_name
eTax['ALAMAT_LENGKAP'] = move.partner_id.contact_address.replace('\n', '').strip() if eTax['NPWP'] == '000000000000000' else commercial_partner.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, rounding_method="DOWN")) # tax amount ALWAYS rounded down
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.move_id._is_downpayment() and x.price_unit < 0 and x.display_type == 'product')
eTax['FG_UANG_MUKA'] = 0
eTax['UANG_MUKA_DPP'] = float_repr(abs(sum(lines.mapped(lambda l: float_round(l.price_subtotal, 0)))), 0)
eTax['UANG_MUKA_PPN'] = float_repr(abs(sum(lines.mapped(lambda l: float_round(l.price_total - l.price_subtotal, 0)))), 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 = 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': float_repr(idr.round(invoice_line_unit_price), idr.decimal_places),
'JUMLAH_BARANG': line.quantity,
'HARGA_TOTAL': idr.round(invoice_line_total_price),
'DPP': line.price_subtotal,
'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': float_round(invoice_line_total_price - line.price_subtotal, 0),
'PPN': free_tax_line,
})
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': float_round(invoice_line_discount_m2m, 0),
'PPN': tax_line,
})
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'] = tax_line
free.remove(f)
sub_total_before_adjustment += sale['DPP']
sub_total_ppn_before_adjustment += sale['PPN']
sale.update({
# Use the db currency rounding to float_round the DPP/PPN.
# As we will correct them we need them to be close to the final result.
'DPP': idr.round(sale['DPP']),
'PPN': idr.round(sale['PPN']),
'DISKON': float_repr(sale['DISKON'], 0),
})
# The total of the base (DPP) and taxes (PPN) must be a integer, equal to the JUMLAH_DPP and JUMLAH_PPN
# To do so, we adjust the first line in order to achieve the correct total
if sales:
diff_dpp = idr.round(eTax['JUMLAH_DPP'] - sum([sale['DPP'] for sale in sales]))
total_sales_ppn = idr.round(eTax['JUMLAH_PPN'] - sum([sale['PPN'] for sale in sales]))
# We will add the differences to the first line for which adding the difference will not result in a negative value.
for sale in sales:
if sale['DPP'] + diff_dpp >= 0 and sale['PPN'] + total_sales_ppn >= 0:
sale['HARGA_TOTAL'] += diff_dpp
sale['DPP'] += diff_dpp
diff_dpp = 0
sale['PPN'] += total_sales_ppn
total_sales_ppn = 0
break
# We couldn't adjust everything in a single line as their values is too low.
# So we will instead slit the adjustment in multiple lines.
if diff_dpp or total_sales_ppn:
for sale in sales:
# DPP
sale_dpp = sale['DPP']
sale["DPP"] = max(0, sale["DPP"] + diff_dpp)
diff_dpp -= (sale["DPP"] - sale_dpp)
sale['HARGA_TOTAL'] = sale["DPP"]
# PPN
sale_ppn = sale['PPN']
sale["PPN"] = max(0, sale["PPN"] + total_sales_ppn)
total_sales_ppn -= (sale["PPN"] - sale_ppn)
# Values now being corrected, we can format them for the CSV
for sale in sales:
sale.update({
'HARGA_TOTAL': float_repr(sale['HARGA_TOTAL'], idr.decimal_places),
'DPP': float_repr(sale['DPP'], idr.decimal_places),
'PPN': float_repr(sale['PPN'], idr.decimal_places),
})
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',
}