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 datetime, timedelta import babel import base64 import pytz _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([ ('tutup_tempo', 'Surat Penutupan Pembayaran Tempo'), ('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"), ("waiting_approval_sales", "Menunggu Approval Sales Manager"), ("waiting_approval_pimpinan", "Menunggu Approval Pimpinan / Kirim Surat"), ("sent", "Approved & Sent") ], default="draft", tracking=True) send_date = fields.Datetime(string="Tanggal Kirim", tracking=True) due_date = fields.Date(string="Tanggal Jatuh Tempo", tracking=True, default= fields.Date.today) seven_days_after_sent_date = fields.Char(string="7 Hari Setelah Tanggal Kirim") 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", ) perihal_label = fields.Char(compute="_compute_perihal_label", string="Perihal Label") payment_difficulty = fields.Selection(string="Payment Difficulty", related='partner_id.payment_difficulty', readonly=True) sales_person_id = fields.Many2one('res.users', string='Salesperson', related='partner_id.user_id', readonly=True) PERIHAL_SEQUENCE = { "penagihan": "sp1", "sp1": "sp2", "sp2": "sp3", } def action_select_all_lines(self): for rec in self: if not rec.line_ids: raise UserError(_("Tidak ada invoice line untuk dipilih.")) rec.line_ids.write({'selected': True}) def action_unselect_all_lines(self): for rec in self: if not rec.line_ids: raise UserError(_("Tidak ada invoice line untuk dihapus seleksinya.")) rec.line_ids.write({'selected': False}) @api.onchange('partner_id') def _onchange_partner_id_domain(self): unpaid_partner_ids = self.env['unpaid.invoice.view'].search([]).mapped('partner_id.id') return { 'domain': { 'partner_id': [('id', 'in', unpaid_partner_ids)] } } def _compute_perihal_label(self): for rec in self: rec.perihal_label = dict(self._fields['perihal'].selection).get(rec.perihal, '') def action_create_next_letter(self): for rec in self: if rec.state != "sent": raise UserError("Surat harus sudah terkirim sebelum bisa membuat surat lanjutan.") next_perihal = self.PERIHAL_SEQUENCE.get(rec.perihal) if not next_perihal: raise UserError("Surat ini sudah pada tahap terakhir (SP3). Tidak bisa membuat lanjutan lagi.") existing = self.search([ ('partner_id', '=', rec.partner_id.id), ('perihal', '=', next_perihal), ('state', '!=', 'draft') # optional: cek hanya yang sudah dikirim ]) if existing: raise UserError(f"Surat lanjutan {dict(self._fields['perihal'].selection).get(next_perihal)} " f"untuk customer ini sudah dibuat: {', '.join(existing.mapped('name'))}") # copy surat lama new_vals = { "tujuan_nama": rec.tujuan_nama, "tujuan_email": rec.tujuan_email, "perihal": next_perihal, "partner_id": rec.partner_id.id, "line_ids": [(0, 0, { 'invoice_id': line.invoice_id.id, 'invoice_number': line.invoice_number, 'invoice_date': line.invoice_date, 'invoice_date_due': line.invoice_date_due, 'invoice_day_to_due': line.invoice_day_to_due, 'new_invoice_day_to_due': line.new_invoice_day_to_due, 'ref': line.ref, 'amount_residual': line.amount_residual, 'currency_id': line.currency_id.id, 'payment_term_id': line.payment_term_id.id, 'date_kirim_tukar_faktur': line.date_kirim_tukar_faktur, 'date_terima_tukar_faktur': line.date_terima_tukar_faktur, 'invoice_user_id': line.invoice_user_id.id, 'sale_id': line.sale_id.id, "selected": line.selected, }) for line in rec.line_ids], } new_letter = self.create(new_vals) self.env.user.notify_info( message=f"{dict(self._fields['perihal'].selection).get(next_perihal)} berhasil dibuat ({new_letter.name}).", title="Informasi", sticky=False ) new_letter.message_post( body= f"{dict(self._fields['perihal'].selection).get(next_perihal)} " f"berhasil dibuat berdasarkan surat sebelumnya.
" f"Nomor Surat: {new_letter.name}" ) rec.message_post( body=( f"Surat lanjutan dengan perihal {dict(self._fields['perihal'].selection).get(next_perihal)} " f"telah dibuat sebagai kelanjutan dari surat ini.
" f"Nomor Surat Baru: {new_letter.name}" ) ) return True @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): wib = pytz.timezone('Asia/Jakarta') now_wib = datetime.now(wib) sales_manager_ids = [19] # ganti dengan ID user Sales Manager pimpinan_user_ids = [7] # ganti dengan ID user Pimpinan for rec in self: # === SP1 s/d SP3 butuh dua tahap approval === if rec.perihal in ("sp1", "sp2", "sp3"): # Tahap 1: Sales Manager approval if rec.state == "waiting_approval_sales": if self.env.user.id not in sales_manager_ids: raise UserError("Hanya Sales Manager yang boleh menyetujui tahap ini.") rec.state = "waiting_approval_pimpinan" rec.message_post(body="Disetujui oleh Sales Manager. Menunggu Approval Pimpinan.") continue # Tahap 2: Pimpinan approval if rec.state == "waiting_approval_pimpinan": if self.env.user.id not in pimpinan_user_ids: raise UserError("Hanya Pimpinan yang berhak menyetujui surat ini.") rec.state = "sent" now_utc = now_wib.astimezone(pytz.UTC).replace(tzinfo=None) rec.send_date = now_utc rec.action_send_letter() rec.message_post(body="Surat Piutang disetujui oleh Pimpinan dan berhasil dikirim.") continue # === Surat penagihan biasa (langsung Pimpinan approve) === if rec.perihal in ("tutup_tempo", "penagihan"): # if self.env.user.id not in pimpinan_user_ids: # raise UserError("Hanya Pimpinan yang boleh menyetujui surat penagihan.") rec.state = "sent" now_utc = now_wib.astimezone(pytz.UTC).replace(tzinfo=None) rec.send_date = now_utc rec.action_send_letter() rec.message_post(body=f"{rec.perihal_label} disetujui dan berhasil dikirim.") self.env.user.notify_info( message=f"Surat piutang {rec.name} berhasil dikirim ke {rec.partner_id.name} ({rec.tujuan_email})", title="Informasi", sticky=False ) def action_print(self): self.ensure_one() if self.perihal == 'tutup_tempo': return self.env.ref('indoteknik_custom.action_report_surat_tutup_tempo').report_action(self) else: return self.env.ref('indoteknik_custom.action_report_surat_piutang').report_action(self) 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 = None report = None body_html = None subject = None # Logika untuk memilih template dan report berdasarkan 'perihal' if self.perihal == 'tutup_tempo': template = self.env.ref('indoteknik_custom.close_tempo_mail_template') report = self.env.ref('indoteknik_custom.action_report_surat_tutup_tempo') due_date_str = self.due_date.strftime('%d %B %Y') if self.due_date else 'yang telah ditentukan' body_html = template.body_html \ .replace('${object.partner_id.name}', self.partner_id.name or '') \ .replace('${object.due_date}', due_date_str or '') subject = f"Pemberitahuan Penutupan Pembayaran Tempo – {self.partner_id.name}" else: template = self.env.ref('indoteknik_custom.letter_receivable_mail_template') 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: 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, monetary=True)} """ 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 '') report = self.env.ref('indoteknik_custom.action_report_surat_piutang') subject = perihal_map.get(self.perihal, self.perihal or '') + " - " + (self.partner_id.name or '') pdf_content, _ = report._render_qweb_pdf([self.id]) attachment_base64 = base64.b64encode(pdf_content) attachment = self.env['ir.attachment'].create({ 'name': f"{self.perihal_label} - {self.partner_id.name}.pdf", 'type': 'binary', 'datas': attachment_base64, 'res_model': 'surat.piutang', 'res_id': self.id, 'mimetype': 'application/pdf', }) cc_list = [ 'finance@indoteknik.co.id', 'akbar@indoteknik.co.id', 'stephan@indoteknik.co.id', 'darren@indoteknik.co.id' ] sales_email = self.sales_person_id.email if self.sales_person_id else None if sales_email and sales_email not in cc_list: cc_list.append(sales_email) values = { 'subject': subject, # Menggunakan subject yang sudah ditentukan di atas 'email_to': self.tujuan_email, 'email_from': 'finance@indoteknik.co.id', 'email_cc': ",".join(sorted(set(cc_list))), # 'email_cc': 'finance@indoteknik.co.id', # testing 'body_html': body_html, # Menggunakan body_html yang sudah ditentukan di atas 'attachments': [(attachment.name, attachment.datas)], 'reply_to': 'finance@indoteknik.co.id', } template.with_context(mail_post_autofollow=False).send_mail( self.id, force_send=True, email_values=values ) _logger.info( f"{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' ) selected_invoice_id = self.env.context.get('default_selected_invoice_id') lines = [(5, 0, 0)] # hapus semua line lama lines += [(0, 0, { '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': True if inv.invoice_id.id == selected_invoice_id else 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() rec.message_post( body=f"Line Invoices diperbarui. Total line saat ini: {len(rec.line_ids)}" ) @api.onchange('perihal', 'partner_id') def _onchange_perihal_tutup_tempo(self): if self.perihal == 'tutup_tempo': for line in self.line_ids: if line.new_invoice_day_to_due < -30: line.selected = True else: line.selected = False else: for line in self.line_ids: line.selected = False @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}" if vals.get("perihal") in ("tutup_tempo", "penagihan"): vals["state"] = "waiting_approval_pimpinan" else: vals["state"] = "waiting_approval_sales" 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') sort = fields.Integer(string='No Urut', compute='_compute_sort', store=False) @api.depends('surat_id.line_ids.selected') def _compute_sort(self): for line in self: if line.surat_id: # Ambil semua line yang selected selected_lines = line.surat_id.line_ids.filtered(lambda l: l.selected) try: line.sort = selected_lines.ids.index(line.id) + 1 except ValueError: line.sort = 0 else: line.sort = 0