1# -*- encoding: utf-8 -*- 2# Part of Odoo. See LICENSE file for full copyright and licensing details. 3 4import base64 5import re 6from odoo import api, fields, models, _ 7from odoo.exceptions import UserError, ValidationError 8 9FK_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'] 10 11LT_HEAD_LIST = ['LT', 'NPWP', 'NAMA', 'JALAN', 'BLOK', 'NOMOR', 'RT', 'RW', 'KECAMATAN', 'KELURAHAN', 'KABUPATEN', 'PROPINSI', 'KODE_POS', 'NOMOR_TELEPON'] 12 13OF_HEAD_LIST = ['OF', 'KODE_OBJEK', 'NAMA', 'HARGA_SATUAN', 'JUMLAH_BARANG', 'HARGA_TOTAL', 'DISKON', 'DPP', 'PPN', 'TARIF_PPNBM', 'PPNBM'] 14 15 16def _csv_row(data, delimiter=',', quote='"'): 17 return quote + (quote + delimiter + quote).join([str(x).replace(quote, '\\' + quote) for x in data]) + quote + '\n' 18 19 20class AccountMove(models.Model): 21 _inherit = "account.move" 22 23 l10n_id_tax_number = fields.Char(string="Tax Number", copy=False) 24 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) 25 l10n_id_attachment_id = fields.Many2one('ir.attachment', readonly=True, copy=False) 26 l10n_id_csv_created = fields.Boolean('CSV Created', compute='_compute_csv_created', copy=False) 27 l10n_id_kode_transaksi = fields.Selection([ 28 ('01', '01 Kepada Pihak yang Bukan Pemungut PPN (Customer Biasa)'), 29 ('02', '02 Kepada Pemungut Bendaharawan (Dinas Kepemerintahan)'), 30 ('03', '03 Kepada Pemungut Selain Bendaharawan (BUMN)'), 31 ('04', '04 DPP Nilai Lain (PPN 1%)'), 32 ('06', '06 Penyerahan Lainnya (Turis Asing)'), 33 ('07', '07 Penyerahan yang PPN-nya Tidak Dipungut (Kawasan Ekonomi Khusus/ Batam)'), 34 ('08', '08 Penyerahan yang PPN-nya Dibebaskan (Impor Barang Tertentu)'), 35 ('09', '09 Penyerahan Aktiva ( Pasal 16D UU PPN )'), 36 ], string='Kode Transaksi', help='Dua digit pertama nomor pajak', 37 readonly=True, states={'draft': [('readonly', False)]}, copy=False) 38 l10n_id_need_kode_transaksi = fields.Boolean(compute='_compute_need_kode_transaksi') 39 40 @api.onchange('partner_id') 41 def _onchange_partner_id(self): 42 self.l10n_id_kode_transaksi = self.partner_id.l10n_id_kode_transaksi 43 return super(AccountMove, self)._onchange_partner_id() 44 45 @api.onchange('l10n_id_tax_number') 46 def _onchange_l10n_id_tax_number(self): 47 for record in self: 48 if record.l10n_id_tax_number and record.move_type not in self.get_purchase_types(): 49 raise UserError(_("You can only change the number manually for a Vendor Bills and Credit Notes")) 50 51 @api.depends('l10n_id_attachment_id') 52 def _compute_csv_created(self): 53 for record in self: 54 record.l10n_id_csv_created = bool(record.l10n_id_attachment_id) 55 56 @api.depends('partner_id') 57 def _compute_need_kode_transaksi(self): 58 for move in self: 59 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' 60 61 @api.constrains('l10n_id_kode_transaksi', 'line_ids') 62 def _constraint_kode_ppn(self): 63 ppn_tag = self.env.ref('l10n_id.ppn_tag') 64 for move in self.filtered(lambda m: m.l10n_id_kode_transaksi != '08'): 65 if any(ppn_tag.id in line.tax_tag_ids.ids for line in move.line_ids if line.exclude_from_invoice_tab is False and not line.display_type) \ 66 and any(ppn_tag.id not in line.tax_tag_ids.ids for line in move.line_ids if line.exclude_from_invoice_tab is False and not line.display_type): 67 raise UserError(_('Cannot mix VAT subject and Non-VAT subject items in the same invoice with this kode transaksi.')) 68 for move in self.filtered(lambda m: m.l10n_id_kode_transaksi == '08'): 69 if any(ppn_tag.id in line.tax_tag_ids.ids for line in move.line_ids if line.exclude_from_invoice_tab is False and not line.display_type): 70 raise UserError('Kode transaksi 08 is only for non VAT subject items.') 71 72 @api.constrains('l10n_id_tax_number') 73 def _constrains_l10n_id_tax_number(self): 74 for record in self.filtered('l10n_id_tax_number'): 75 if record.l10n_id_tax_number != re.sub(r'\D', '', record.l10n_id_tax_number): 76 record.l10n_id_tax_number = re.sub(r'\D', '', record.l10n_id_tax_number) 77 if len(record.l10n_id_tax_number) != 16: 78 raise UserError(_('A tax number should have 16 digits')) 79 elif record.l10n_id_tax_number[:2] not in dict(self._fields['l10n_id_kode_transaksi'].selection).keys(): 80 raise UserError(_('A tax number must begin by a valid Kode Transaksi')) 81 elif record.l10n_id_tax_number[2] not in ('0', '1'): 82 raise UserError(_('The third digit of a tax number must be 0 or 1')) 83 84 def _post(self, soft=True): 85 """Set E-Faktur number after validation.""" 86 for move in self: 87 if move.l10n_id_need_kode_transaksi: 88 if not move.l10n_id_kode_transaksi: 89 raise ValidationError(_('You need to put a Kode Transaksi for this partner.')) 90 if move.l10n_id_replace_invoice_id.l10n_id_tax_number: 91 if not move.l10n_id_replace_invoice_id.l10n_id_attachment_id: 92 raise ValidationError(_('Replacement invoice only for invoices on which the e-Faktur is generated. ')) 93 rep_efaktur_str = move.l10n_id_replace_invoice_id.l10n_id_tax_number 94 move.l10n_id_tax_number = '%s1%s' % (move.l10n_id_kode_transaksi, rep_efaktur_str[3:]) 95 else: 96 efaktur = self.env['l10n_id_efaktur.efaktur.range'].pop_number(move.company_id.id) 97 if not efaktur: 98 raise ValidationError(_('There is no Efaktur number available. Please configure the range you get from the government in the e-Faktur menu. ')) 99 move.l10n_id_tax_number = '%s0%013d' % (str(move.l10n_id_kode_transaksi), efaktur) 100 return super()._post(soft) 101 102 def reset_efaktur(self): 103 """Reset E-Faktur, so it can be use for other invoice.""" 104 for move in self: 105 if move.l10n_id_attachment_id: 106 raise UserError(_('You have already generated the tax report for this document: %s', move.name)) 107 self.env['l10n_id_efaktur.efaktur.range'].push_number(move.company_id.id, move.l10n_id_tax_number[3:]) 108 move.message_post( 109 body='e-Faktur Reset: %s ' % (move.l10n_id_tax_number), 110 subject="Reset Efaktur") 111 move.l10n_id_tax_number = False 112 return True 113 114 def download_csv(self): 115 action = { 116 'type': 'ir.actions.act_url', 117 '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, 118 'target': 'self' 119 } 120 return action 121 122 def download_efaktur(self): 123 """Collect the data and execute function _generate_efaktur.""" 124 for record in self: 125 if record.state == 'draft': 126 raise ValidationError(_('Could not download E-faktur in draft state')) 127 128 if record.partner_id.l10n_id_pkp and not record.l10n_id_tax_number: 129 raise ValidationError(_('Connect %(move_number)s with E-faktur to download this report', move_number=record.name)) 130 131 self._generate_efaktur(',') 132 return self.download_csv() 133 134 def _generate_efaktur_invoice(self, delimiter): 135 """Generate E-Faktur for customer invoice.""" 136 # Invoice of Customer 137 company_id = self.company_id 138 dp_product_id = self.env['ir.config_parameter'].sudo().get_param('sale.default_deposit_product_id') 139 140 output_head = '%s%s%s' % ( 141 _csv_row(FK_HEAD_LIST, delimiter), 142 _csv_row(LT_HEAD_LIST, delimiter), 143 _csv_row(OF_HEAD_LIST, delimiter), 144 ) 145 146 for move in self.filtered(lambda m: m.state == 'posted'): 147 eTax = move._prepare_etax() 148 149 nik = str(move.partner_id.l10n_id_nik) if not move.partner_id.vat else '' 150 151 if move.l10n_id_replace_invoice_id: 152 number_ref = str(move.l10n_id_replace_invoice_id.name) + " replaced by " + str(move.name) + " " + nik 153 else: 154 number_ref = str(move.name) + " " + nik 155 156 street = ', '.join([x for x in (move.partner_id.street, move.partner_id.street2) if x]) 157 158 invoice_npwp = '000000000000000' 159 if move.partner_id.vat and len(move.partner_id.vat) >= 12: 160 invoice_npwp = move.partner_id.vat 161 elif (not move.partner_id.vat or len(move.partner_id.vat) < 12) and move.partner_id.l10n_id_nik: 162 invoice_npwp = move.partner_id.l10n_id_nik 163 invoice_npwp = invoice_npwp.replace('.', '').replace('-', '') 164 165 # Here all fields or columns based on eTax Invoice Third Party 166 eTax['KD_JENIS_TRANSAKSI'] = move.l10n_id_tax_number[0:2] or 0 167 eTax['FG_PENGGANTI'] = move.l10n_id_tax_number[2:3] or 0 168 eTax['NOMOR_FAKTUR'] = move.l10n_id_tax_number[3:] or 0 169 eTax['MASA_PAJAK'] = move.invoice_date.month 170 eTax['TAHUN_PAJAK'] = move.invoice_date.year 171 eTax['TANGGAL_FAKTUR'] = '{0}/{1}/{2}'.format(move.invoice_date.day, move.invoice_date.month, move.invoice_date.year) 172 eTax['NPWP'] = invoice_npwp 173 eTax['NAMA'] = move.partner_id.name if eTax['NPWP'] == '000000000000000' else move.partner_id.l10n_id_tax_name or move.partner_id.name 174 eTax['ALAMAT_LENGKAP'] = move.partner_id.contact_address.replace('\n', '') if eTax['NPWP'] == '000000000000000' else move.partner_id.l10n_id_tax_address or street 175 eTax['JUMLAH_DPP'] = int(round(move.amount_untaxed, 0)) # currency rounded to the unit 176 eTax['JUMLAH_PPN'] = int(round(move.amount_tax, 0)) 177 eTax['ID_KETERANGAN_TAMBAHAN'] = '1' if move.l10n_id_kode_transaksi == '07' else '' 178 eTax['REFERENSI'] = number_ref 179 180 lines = move.line_ids.filtered(lambda x: x.product_id.id == int(dp_product_id) and x.price_unit < 0 and not x.display_type) 181 eTax['FG_UANG_MUKA'] = 0 182 eTax['UANG_MUKA_DPP'] = int(abs(sum(lines.mapped('price_subtotal')))) 183 eTax['UANG_MUKA_PPN'] = int(abs(sum(lines.mapped(lambda l: l.price_total - l.price_subtotal)))) 184 185 company_npwp = company_id.partner_id.vat or '000000000000000' 186 187 fk_values_list = ['FK'] + [eTax[f] for f in FK_HEAD_LIST[1:]] 188 eTax['JALAN'] = company_id.partner_id.l10n_id_tax_address or company_id.partner_id.street 189 eTax['NOMOR_TELEPON'] = company_id.phone or '' 190 191 lt_values_list = ['FAPR', company_npwp, company_id.name] + [eTax[f] for f in LT_HEAD_LIST[3:]] 192 193 # HOW TO ADD 2 line to 1 line for free product 194 free, sales = [], [] 195 196 for line in move.line_ids.filtered(lambda l: not l.exclude_from_invoice_tab and not l.display_type): 197 # *invoice_line_unit_price is price unit use for harga_satuan's column 198 # *invoice_line_quantity is quantity use for jumlah_barang's column 199 # *invoice_line_total_price is bruto price use for harga_total's column 200 # *invoice_line_discount_m2m is discount price use for diskon's column 201 # *line.price_subtotal is subtotal price use for dpp's column 202 # *tax_line or free_tax_line is tax price use for ppn's column 203 free_tax_line = tax_line = bruto_total = total_discount = 0.0 204 205 for tax in line.tax_ids: 206 if tax.amount > 0: 207 tax_line += line.price_subtotal * (tax.amount / 100.0) 208 209 invoice_line_unit_price = line.price_unit 210 211 invoice_line_total_price = invoice_line_unit_price * line.quantity 212 213 line_dict = { 214 'KODE_OBJEK': line.product_id.default_code or '', 215 'NAMA': line.product_id.name or '', 216 'HARGA_SATUAN': int(invoice_line_unit_price), 217 'JUMLAH_BARANG': line.quantity, 218 'HARGA_TOTAL': int(invoice_line_total_price), 219 'DPP': int(line.price_subtotal), 220 'product_id': line.product_id.id, 221 } 222 223 if line.price_subtotal < 0: 224 for tax in line.tax_ids: 225 free_tax_line += (line.price_subtotal * (tax.amount / 100.0)) * -1.0 226 227 line_dict.update({ 228 'DISKON': int(invoice_line_total_price - line.price_subtotal), 229 'PPN': int(free_tax_line), 230 }) 231 free.append(line_dict) 232 elif line.price_subtotal != 0.0: 233 invoice_line_discount_m2m = invoice_line_total_price - line.price_subtotal 234 235 line_dict.update({ 236 'DISKON': int(invoice_line_discount_m2m), 237 'PPN': int(tax_line), 238 }) 239 sales.append(line_dict) 240 241 sub_total_before_adjustment = sub_total_ppn_before_adjustment = 0.0 242 243 # We are finding the product that has affected 244 # by free product to adjustment the calculation 245 # of discount and subtotal. 246 # - the price total of free product will be 247 # included as a discount to related of product. 248 for sale in sales: 249 for f in free: 250 if f['product_id'] == sale['product_id']: 251 sale['DISKON'] = sale['DISKON'] - f['DISKON'] + f['PPN'] 252 sale['DPP'] = sale['DPP'] + f['DPP'] 253 254 tax_line = 0 255 256 for tax in line.tax_ids: 257 if tax.amount > 0: 258 tax_line += sale['DPP'] * (tax.amount / 100.0) 259 260 sale['PPN'] = int(tax_line) 261 262 free.remove(f) 263 264 sub_total_before_adjustment += sale['DPP'] 265 sub_total_ppn_before_adjustment += sale['PPN'] 266 bruto_total += sale['DISKON'] 267 total_discount += round(sale['DISKON'], 2) 268 269 output_head += _csv_row(fk_values_list, delimiter) 270 output_head += _csv_row(lt_values_list, delimiter) 271 for sale in sales: 272 of_values_list = ['OF'] + [str(sale[f]) for f in OF_HEAD_LIST[1:-2]] + ['0', '0'] 273 output_head += _csv_row(of_values_list, delimiter) 274 275 return output_head 276 277 def _prepare_etax(self): 278 # These values are never set 279 return {'JUMLAH_PPNBM': 0, 'UANG_MUKA_PPNBM': 0, 'BLOK': '', 'NOMOR': '', 'RT': '', 'RW': '', 'KECAMATAN': '', 'KELURAHAN': '', 'KABUPATEN': '', 'PROPINSI': '', 'KODE_POS': '', 'JUMLAH_BARANG': 0, 'TARIF_PPNBM': 0, 'PPNBM': 0} 280 281 def _generate_efaktur(self, delimiter): 282 if self.filtered(lambda x: not x.l10n_id_kode_transaksi): 283 raise UserError(_('Some documents don\'t have a transaction code')) 284 if self.filtered(lambda x: x.move_type != 'out_invoice'): 285 raise UserError(_('Some documents are not Customer Invoices')) 286 287 output_head = self._generate_efaktur_invoice(delimiter) 288 my_utf8 = output_head.encode("utf-8") 289 out = base64.b64encode(my_utf8) 290 291 attachment = self.env['ir.attachment'].create({ 292 'datas': out, 293 'name': 'efaktur_%s.csv' % (fields.Datetime.to_string(fields.Datetime.now()).replace(" ", "_")), 294 'type': 'binary', 295 }) 296 297 for record in self: 298 record.message_post(attachment_ids=[attachment.id]) 299 self.l10n_id_attachment_id = attachment.id 300 return { 301 'type': 'ir.actions.client', 302 'tag': 'reload', 303 } 304