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"""