from odoo import models, fields, api, _ from odoo.exceptions import UserError from odoo.exceptions import ValidationError from odoo.tools import mail, formatLang from terbilang import Terbilang import re import logging from datetime import timedelta import babel _logger = logging.getLogger(__name__) class SuratPiutang(models.Model): _name = "surat.piutang" _description = "Surat Piutang" _inherit = ['mail.thread', 'mail.activity.mixin'] _order = 'name desc' name = fields.Char(string="Nomor Surat", readonly=True, copy=False) partner_id = fields.Many2one("res.partner", string="Customer", required=True, tracking=True) tujuan_nama = fields.Char(string="Nama Tujuan", tracking=True) tujuan_email = fields.Char(string="Email Tujuan", tracking=True) perihal = fields.Selection([ ('penagihan', 'Surat Resmi Penagihan'), ('sp1', 'Surat Peringatan Piutang ke-1'), ('sp2', 'Surat Peringatan Piutang ke-2'), ('sp3', 'Surat Peringatan Piutang ke-3') ], string="Perihal", required=True, tracking=True) line_ids = fields.One2many("surat.piutang.line", "surat_id", string="Invoice Lines") state = fields.Selection([ ("draft", "Draft"), ("approval_pimpinan", "Menunggu Approval Pimpinan"), ("sent", "Sent") ], default="draft", tracking=True) send_date = fields.Datetime(string="Tanggal Kirim", tracking=True) seven_days_after_sent_date = fields.Char(string="7 Hari Setelah Tanggal Kirim", tracking=True) periode_invoices_terpilih = fields.Char( string="Periode Invoices Terpilih", compute="_compute_periode_invoices", ) currency_id = fields.Many2one('res.currency') # Grand total (total sisa semua line yang dicentang) grand_total = fields.Monetary( string='Total Sisa', currency_field='currency_id', compute='_compute_grand_total', ) grand_total_text = fields.Char( string="Total Terbilang", compute="_compute_grand_total_text", ) def action_print_surat_piutang(self): self.ensure_one() return self.env.ref('indoteknik_custom.report_surat_piutang_formal').report_action(self) @api.depends("line_ids.selected", "line_ids.invoice_date") def _compute_periode_invoices(self): for rec in self: selected_lines = rec.line_ids.filtered(lambda l: l.selected and l.invoice_date) if not selected_lines: rec.periode_invoices_terpilih = "-" continue dates = selected_lines.mapped("invoice_date") min_date, max_date = min(dates), max(dates) # Ambil bagian bulan & tahun min_month = babel.dates.format_date(min_date, "MMMM", locale="id_ID") min_year = min_date.year max_month = babel.dates.format_date(max_date, "MMMM", locale="id_ID") max_year = max_date.year if min_year == max_year: if min_month == max_month: # example: Januari 2025 rec.periode_invoices_terpilih = f"{min_month} {min_year}" else: # example: Mei s/d Juni 2025 rec.periode_invoices_terpilih = f"{min_month} s/d {max_month} {max_year}" else: # example: Desember 2024 s/d Januari 2025 rec.periode_invoices_terpilih = f"{min_month} {min_year} s/d {max_month} {max_year}" def _compute_grand_total_text(self): tb = Terbilang() for record in self: res = "" if record.grand_total and record.grand_total > 0: try: tb.parse(int(record.grand_total)) res = tb.getresult().title() + " Rupiah" except Exception: res = "" record.grand_total_text = res @api.depends('line_ids.amount_residual', 'line_ids.selected') def _compute_grand_total(self): for rec in self: rec.grand_total = sum( line.amount_residual or 0.0 for line in rec.line_ids if line.selected ) @api.constrains("tujuan_email") def _check_email_format(self): for rec in self: if rec.tujuan_email and not mail.single_email_re.match(rec.tujuan_email): raise ValidationError(_("Format email tidak valid: %s") % rec.tujuan_email) def action_approve(self): pimpinan_user_ids = [7] # Pak Akbar if self.env.user.id not in pimpinan_user_ids: raise UserError("Hanya Pimpinan yang berhak menyetujui tahap ini.") for rec in self: if rec.state == "approval_pimpinan": rec.state = "sent" rec.send_date = fields.Datetime.now() # Format tanggal + bulan (tanpa tahun) month_map = { 1: "Januari", 2: "Februari", 3: "Maret", 4: "April", 5: "Mei", 6: "Juni", 7: "Juli", 8: "Agustus", 9: "September", 10: "Oktober", 11: "November", 12: "Desember", } if rec.send_date: target_date = rec.send_date.date() + timedelta(days=7) rec.seven_days_after_sent_date = f"{target_date.day} {month_map[target_date.month]}" def action_send_letter(self): self.ensure_one() selected_lines = self.line_ids.filtered('selected') if not selected_lines: raise UserError(_("Tidak ada invoice yang dicentang untuk dikirim.")) if not self.tujuan_email: raise UserError(_("Email tujuan harus diisi.")) template = self.env.ref('indoteknik_custom.letter_receivable_mail_template') # today = fields.Date.today() month_map = { 1: "Januari", 2: "Februari", 3: "Maret", 4: "April", 5: "Mei", 6: "Juni", 7: "Juli", 8: "Agustus", 9: "September", 10: "Oktober", 11: "November", 12: "Desember", } target_date = (self.send_date or fields.Datetime.now()).date() + timedelta(days=7) self.seven_days_after_sent_date = f"{target_date.day} {month_map[target_date.month]}" perihal_map = { 'penagihan': 'Surat Resmi Penagihan', 'sp1': 'Surat Peringatan Pertama (I)', 'sp2': 'Surat Peringatan Kedua (II)', 'sp3': 'Surat Peringatan Ketiga (III)', } perihal_text = perihal_map.get(self.perihal, self.perihal or '') invoice_table_rows = "" grand_total = 0 for line in selected_lines: # days_to_due = (line.invoice_date_due - today).days if line.invoice_date_due else 0 grand_total += line.amount_residual invoice_table_rows += f""" {line.invoice_number or '-'} {self.partner_id.name or '-'} {fields.Date.to_string(line.invoice_date) or '-'} {fields.Date.to_string(line.invoice_date_due) or '-'} {line.new_invoice_day_to_due} {line.ref or '-'} {formatLang(self.env, line.amount_residual, currency_obj=line.currency_id)} {line.payment_term_id.name or '-'} """ invoice_table_footer = f""" Grand Total {formatLang(self.env, grand_total, currency_obj=self.currency_id)} """ # inject table rows ke template body_html = re.sub( r"]*>.*?", f"{invoice_table_rows}{invoice_table_footer}", template.body_html, flags=re.DOTALL ).replace('${object.name}', self.name or '') \ .replace('${object.partner_id.name}', self.partner_id.name or '') \ .replace('${object.seven_days_after_sent_date}', self.seven_days_after_sent_date or '') \ .replace('${object.perihal}', perihal_text or '') values = { 'subject': template.subject.replace('${object.name}', self.name or ''), 'email_to': self.tujuan_email, 'email_from': 'finance@indoteknik.co.id', 'body_html': body_html, 'reply_to': 'finance@indoteknik.co.id', } template.send_mail(self.id, force_send=True, email_values=values) _logger.info( f"Surat Piutang {self.name} terkirim ke {self.tujuan_email} " f"({self.partner_id.name}), total {len(selected_lines)} invoice." ) @api.onchange('partner_id') def _onchange_partner_id(self): if self.partner_id: invoice_lines = self.env['unpaid.invoice.view'].search( [('partner_id', '=', self.partner_id.id)], order='new_invoice_day_to_due asc' ) lines = [(0, 0, { # 'invoice_view_id': inv.id, 'invoice_id': inv.invoice_id.id, 'invoice_number': inv.invoice_number, 'invoice_date': inv.invoice_date, 'invoice_date_due': inv.invoice_date_due, 'invoice_day_to_due': inv.invoice_day_to_due, 'new_invoice_day_to_due': inv.new_invoice_day_to_due, 'ref': inv.ref, 'amount_residual': inv.amount_residual, 'currency_id': inv.currency_id.id, 'payment_term_id': inv.payment_term_id.id, 'date_kirim_tukar_faktur': inv.date_kirim_tukar_faktur, 'date_terima_tukar_faktur': inv.date_terima_tukar_faktur, 'invoice_user_id': inv.invoice_user_id.id, 'sale_id': inv.sale_id.id, 'selected': False }) for inv in invoice_lines] self.line_ids = lines def action_refresh_lines(self): for rec in self: if not rec.partner_id: continue # Ambil semua unpaid terbaru invoice_views = self.env['unpaid.invoice.view'].search( [('partner_id', '=', rec.partner_id.id)], order='new_invoice_day_to_due asc' ) existing_lines = {line.invoice_id.id: line for line in rec.line_ids} # Cache selected status per invoice id selected_map = {line.invoice_id.id: line.selected for line in rec.line_ids} # Invoice id yang masih ada di unpaid new_invoice_ids = invoice_views.mapped('invoice_id.id') for inv in invoice_views: if inv.invoice_id.id in existing_lines: # update line lama line = existing_lines[inv.invoice_id.id] line.write({ # 'invoice_view_id': inv.id, 'invoice_number': inv.invoice_number, 'invoice_date': inv.invoice_date, 'invoice_date_due': inv.invoice_date_due, 'invoice_day_to_due': inv.invoice_day_to_due, 'new_invoice_day_to_due': inv.new_invoice_day_to_due, 'ref': inv.ref, 'amount_residual': inv.amount_residual, 'currency_id': inv.currency_id.id, 'payment_term_id': inv.payment_term_id.id, 'date_kirim_tukar_faktur': inv.date_kirim_tukar_faktur, 'date_terima_tukar_faktur': inv.date_terima_tukar_faktur, 'invoice_user_id': inv.invoice_user_id.id, 'sale_id': inv.sale_id.id, 'selected': selected_map.get(inv.invoice_id.id, line.selected), }) else: # preserve selected kalau pernah ada di cache self.env['surat.piutang.line'].create({ 'surat_id': rec.id, # 'invoice_view_id': inv.id, 'invoice_id': inv.invoice_id.id, 'invoice_number': inv.invoice_number, 'invoice_date': inv.invoice_date, 'invoice_date_due': inv.invoice_date_due, 'invoice_day_to_due': inv.invoice_day_to_due, 'new_invoice_day_to_due': inv.new_invoice_day_to_due, 'ref': inv.ref, 'amount_residual': inv.amount_residual, 'currency_id': inv.currency_id.id, 'payment_term_id': inv.payment_term_id.id, 'date_kirim_tukar_faktur': inv.date_kirim_tukar_faktur, 'date_terima_tukar_faktur': inv.date_terima_tukar_faktur, 'invoice_user_id': inv.invoice_user_id.id, 'sale_id': inv.sale_id.id, 'selected': selected_map.get(inv.invoice_id.id, False), }) # Hapus line yang tidak ada lagi di unpaid view rec.line_ids.filtered(lambda l: l.invoice_id.id not in new_invoice_ids).unlink() @api.model def create(self, vals): # Generate nomor surat otomatis if not vals.get("name"): seq = self.env["ir.sequence"].next_by_code("surat.piutang") or "000" today = fields.Date.today() bulan_romawi = ["I","II","III","IV","V","VI","VII","VIII","IX","X","XI","XII"][today.month-1] tahun = today.strftime("%y") vals["name"] = f"{seq}/LO/FAT/IDG/{bulan_romawi}/{tahun}" vals["state"] = "approval_pimpinan" return super().create(vals) class SuratPiutangLine(models.Model): _name = 'surat.piutang.line' _description = 'Surat Piutang Line' surat_id = fields.Many2one('surat.piutang', string='Surat Piutang', ondelete='cascade') # invoice_view_id = fields.Many2one('unpaid.invoice.view', string='Unpaid Invoice') invoice_id = fields.Many2one('account.move', string='Invoice') selected = fields.Boolean(string="Pilih", default=False) invoice_number = fields.Char(string='Invoice Number') invoice_date = fields.Date(string='Invoice Date') invoice_date_due = fields.Date(string='Due Date') invoice_day_to_due = fields.Integer(string='Day to Due') new_invoice_day_to_due = fields.Integer(string='New Day to Due') ref = fields.Char(string='Reference') amount_residual = fields.Monetary(string='Amount Due Signed') currency_id = fields.Many2one('res.currency') payment_term_id = fields.Many2one('account.payment.term', string='Payment Terms') date_kirim_tukar_faktur = fields.Date(string='Kirim Faktur') date_terima_tukar_faktur = fields.Date(string='Terima Faktur') invoice_user_id = fields.Many2one('res.users', string='Salesperson') sale_id = fields.Many2one('sale.order', string='Sale Order')