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')