summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-xindoteknik_custom/__manifest__.py4
-rwxr-xr-xindoteknik_custom/models/__init__.py4
-rw-r--r--indoteknik_custom/models/account_move.py2
-rw-r--r--indoteknik_custom/models/down_payment.py1088
-rw-r--r--indoteknik_custom/models/letter_receivable.py390
-rw-r--r--indoteknik_custom/models/unpaid_invoice_view.py48
-rw-r--r--indoteknik_custom/report/report_surat_piutang copy.xml149
-rw-r--r--indoteknik_custom/report/report_surat_piutang.xml241
-rwxr-xr-xindoteknik_custom/security/ir.model.access.csv5
-rw-r--r--indoteknik_custom/views/ir_sequence.xml9
-rw-r--r--indoteknik_custom/views/letter_receivable.xml148
-rw-r--r--indoteknik_custom/views/letter_receivable_mail_template.xml77
-rw-r--r--indoteknik_custom/views/unpaid_invoice_view.xml92
13 files changed, 2254 insertions, 3 deletions
diff --git a/indoteknik_custom/__manifest__.py b/indoteknik_custom/__manifest__.py
index 09a3aa6f..f2145d0c 100755
--- a/indoteknik_custom/__manifest__.py
+++ b/indoteknik_custom/__manifest__.py
@@ -166,6 +166,7 @@
'report/report_invoice.xml',
'report/report_picking.xml',
'report/report_sale_order.xml',
+ 'report/report_surat_piutang.xml',
'report/purchase_report.xml',
'views/vendor_sla.xml',
'views/coretax_faktur.xml',
@@ -178,6 +179,9 @@
'views/tukar_guling_po.xml',
# 'views/refund_sale_order.xml',
'views/update_date_planned_po_wizard_view.xml',
+ 'views/unpaid_invoice_view.xml',
+ 'views/letter_receivable.xml',
+ 'views/letter_receivable_mail_template.xml'
# 'views/reimburse.xml',
],
'demo': [],
diff --git a/indoteknik_custom/models/__init__.py b/indoteknik_custom/models/__init__.py
index 3a9f9312..c8910669 100755
--- a/indoteknik_custom/models/__init__.py
+++ b/indoteknik_custom/models/__init__.py
@@ -156,4 +156,6 @@ from . import refund_sale_order
# from . import patch
from . import tukar_guling
from . import tukar_guling_po
-from . import update_date_planned_po_wizard \ No newline at end of file
+from . import update_date_planned_po_wizard
+from . import unpaid_invoice_view
+from . import letter_receivable \ No newline at end of file
diff --git a/indoteknik_custom/models/account_move.py b/indoteknik_custom/models/account_move.py
index 70cd07e4..4fb3db22 100644
--- a/indoteknik_custom/models/account_move.py
+++ b/indoteknik_custom/models/account_move.py
@@ -422,7 +422,7 @@ class AccountMove(models.Model):
'reply_to': 'finance@indoteknik.co.id',
}
- template.send_mail(invs[0].id, force_send=True, email_values=values)
+ # template.send_mail(invs[0].id, force_send=True, email_values=values)
_logger.info(f"Mengirim email ke: {values['email_to']} > email CC: {values['email_cc']}")
_logger.info(f"Reminder terkirim ke {partner.name} ({values['email_to']}) → {len(invs)} invoice (dtd = {dtd})")
# flag
diff --git a/indoteknik_custom/models/down_payment.py b/indoteknik_custom/models/down_payment.py
new file mode 100644
index 00000000..5adbafd9
--- /dev/null
+++ b/indoteknik_custom/models/down_payment.py
@@ -0,0 +1,1088 @@
+from odoo import models, api, fields, _
+from odoo.exceptions import UserError, ValidationError
+from datetime import date, datetime, timedelta
+# import datetime
+import logging
+_logger = logging.getLogger(__name__)
+from terbilang import Terbilang
+import pytz
+from pytz import timezone
+import base64
+
+
+class DownPayment(models.Model):
+ _name = 'down.payment'
+ _description = 'Down Payment Management'
+ _rec_name = 'number'
+ _inherit = ['mail.thread', 'mail.activity.mixin']
+
+ user_id = fields.Many2one('res.users', string='Diajukan Oleh', default=lambda self: self.env.user, tracking=3)
+ partner_id = fields.Many2one('res.partner', string='Partner', related='user_id.partner_id', readonly=True)
+
+ number = fields.Char(string='No. Dokumen', default='New Draft', tracking=3)
+
+ applicant_name = fields.Char(string='Nama Pemohon', tracking=3, required=True)
+ nominal = fields.Float(string='Nominal', tracking=3, required=True)
+
+ bank_name = fields.Char(string='Bank', tracking=3, required=True)
+ account_name = fields.Char(string='Nama Account', tracking=3, required=True)
+ bank_account = fields.Char(string='No. Rekening', tracking=3, required=True)
+ detail_note = fields.Text(string='Keterangan Penggunaan Rinci', tracking=3)
+
+ date_back_to_office = fields.Date(
+ string='Tanggal Kembali ke Kantor',
+ tracking=3,
+ required=True
+ )
+
+ estimated_return_date = fields.Date(
+ string='Batas Pengajuan',
+ help='Tanggal batas maksimal pengajuan realisasi setelah kembali ke kantor. '
+ '7 hari setelah tanggal kembali.'
+ )
+
+ days_remaining = fields.Integer(
+ string='Sisa Hari Pengajuan',
+ compute='_compute_days_remaining',
+ help='Sisa hari batas maksimal pengajuan realisasi setelah kembali ke kantor. '
+ '7 hari setelah tanggal kembali.'
+ )
+
+ status = fields.Selection([
+ ('draft', 'Draft'),
+ ('pengajuan1', 'Menunggu Approval Departement'),
+ ('pengajuan2', 'Menunggu Pengecekan AP'),
+ ('pengajuan3', 'Menunggu Approval Pimpinan'),
+ ('approved', 'Approved'),
+ ('reject', 'Rejected')
+ ], string='Status', default='draft', tracking=3, index=True, track_visibility='onchange')
+
+ last_status = fields.Selection([
+ ('draft', 'Draft'),
+ ('pengajuan1', 'Menunggu Approval Departement'),
+ ('pengajuan2', 'Menunggu Pengecekan AP'),
+ ('pengajuan3', 'Menunggu Approval Pimpinan'),
+ ('approved', 'Approved'),
+ ('reject', 'Rejected')
+ ], string='Status')
+
+ status_pay_down_payment = fields.Selection([
+ ('pending', 'Pending'),
+ ('payment', 'Payment'),
+ ], string='Status Pembayaran', default='pending', tracking=3)
+
+ name_approval_departement = fields.Char(string='Approval Departement', tracking=True)
+ name_approval_ap = fields.Char(string='Approval AP', tracking=True)
+ email_ap = fields.Char(string = 'Email AP')
+ name_approval_pimpinan = fields.Char(string='Approval Pimpinan', tracking=True)
+
+ date_approved_department = fields.Datetime(string="Date Approved Department")
+ date_approved_ap = fields.Datetime(string="Date Approved AP")
+ date_approved_pimpinan = fields.Datetime(string="Date Approved Pimpinan")
+
+ position_department = fields.Char(string='Position Departement', tracking=True)
+ position_ap = fields.Char(string='Position AP', tracking=True)
+ position_pimpinan = fields.Char(string='Position Pimpinan', tracking=True)
+
+ approved_by = fields.Char(string='Approved By', tracking=True, track_visibility='always')
+
+ departement_type = fields.Selection([
+ ('sales', 'Sales'),
+ ('merchandiser', 'Merchandiser'),
+ ('marketing', 'Marketing'),
+ ('logistic', 'Logistic'),
+ ('procurement', 'Procurement'),
+ ('fat', 'FAT'),
+ ('hr_ga', 'HR & GA'),
+ ], string='Departement Type', tracking=3, required=True)
+
+ attachment_file_image = fields.Binary(string='Attachment Image', attachment_filename='attachment_filename_image')
+ attachment_file_pdf = fields.Binary(string='Attachment PDF', attachment_filename='attachment_filename_pdf')
+ attachment_filename_image = fields.Char(string='Filename Image')
+ attachment_filename_pdf = fields.Char(string='Filename PDF')
+
+ attachment_type = fields.Selection([
+ ('pdf', 'PDF'),
+ ('image', 'Image'),
+ ], string="Attachment Type", default='pdf')
+
+ move_id = fields.Many2one('account.move', string='Journal Entries', domain=[('move_type', '=', 'entry')])
+ is_cab_visible = fields.Boolean(string='Is Journal Uang Muka Visible', compute='_compute_is_cab_visible')
+
+ reason_reject = fields.Text(string='Alasan Penolakan')
+
+ currency_id = fields.Many2one(
+ 'res.currency', string='Currency',
+ default=lambda self: self.env.company.currency_id
+ )
+
+ @api.onchange('nominal')
+ def _onchange_nominal_no_minus(self):
+ if self.nominal and self.nominal < 0:
+ self.nominal = 0
+ return {
+ 'warning': {
+ 'title': _('Nominal Tidak Valid'),
+ 'message': _(
+ "Nominal tidak boleh diisi minus.\n"
+ "Nilai di set menjadi nol."
+ )
+ }
+ }
+
+ def _get_jasper_attachment(self):
+ self.ensure_one()
+ report = self.env['ir.actions.report'].browse(1134) # ID Downpayment Report
+ if not report:
+ raise UserError("Report Jasper tidak ditemukan.")
+
+ data = report.render_jasper(self.ids, data={})[0]
+ filename = f"{self.number}.pdf"
+ return {
+ 'name': filename,
+ 'datas': base64.b64encode(data),
+ 'type': 'binary',
+ 'mimetype': 'application/pdf',
+ 'filename': filename,
+ }
+
+ def action_send_pum_reminder(self):
+ """
+ Kirim email reminder PUM otomatis.
+ - Hari ini = kirim dengan template 'mail_template_pum_reminder_today'
+ - H-2 dari due date = kirim dengan template 'mail_template_pum_reminder_h_2'
+ """
+ today = date.today()
+ pum_ids = self.search([
+ ('date_back_to_office', '!=', False),
+ ('status', 'not in', ['draft', 'reject']),
+ ])
+
+ template_today = self.env.ref('indoteknik_custom.mail_template_pum_reminder_today', raise_if_not_found=False)
+ template_h2 = self.env.ref('indoteknik_custom.mail_template_pum_reminder_h_2', raise_if_not_found=False)
+
+ if not template_today or not template_h2:
+ _logger.warning("Salah satu template email tidak ditemukan.")
+ return
+
+ for pum in pum_ids:
+ _logger.info(f"[REMINDER] Memproses PUM {pum.number}")
+
+ if not pum.email_ap or not pum.user_id.partner_id.email:
+ _logger.warning(f"[REMINDER] Lewati PUM {pum.number} karena email_ap atau email user kosong.")
+ continue
+
+ due_date = pum.date_back_to_office + timedelta(days=7)
+ days_remaining = (due_date - today).days
+
+ realization = self.env['realization.down.payment'].search([('pum_id', '=', pum.id)], limit=1)
+ if not realization or realization.done_status != 'remaining':
+ _logger.info(f"[REMINDER] Lewati PUM {pum.number}, status realisasi bukan 'remaining'.")
+ continue
+
+ # Tentukan template
+ if pum.date_back_to_office == today:
+ template = template_today
+ elif days_remaining == 2:
+ template = template_h2
+ else:
+ _logger.info(f"[REMINDER] Lewati PUM {pum.number}, hari ini bukan tanggal pengingat.")
+ continue
+
+ # Generate attachment
+ try:
+ attachment_vals = pum._get_jasper_attachment()
+ attachment = self.env['ir.attachment'].create({
+ 'name': attachment_vals['name'],
+ 'type': 'binary',
+ 'datas': attachment_vals['datas'],
+ 'res_model': 'down.payment',
+ 'res_id': pum.id,
+ 'mimetype': 'application/pdf',
+ })
+ except Exception as e:
+ _logger.error(f"[REMINDER] Gagal membuat attachment untuk PUM {pum.number}: {str(e)}")
+ continue
+
+ email_values = {
+ # 'email_to': pum.user_id.partner_id.email,
+ 'email_to': 'andrifebriyadiputra@gmail.com',
+ 'email_from': pum.email_ap,
+ 'attachment_ids': [(6, 0, [attachment.id])],
+ }
+
+ _logger.info(f"[REMINDER] Mengirim email PUM {pum.number} ke {email_values['email_to']} dari {email_values['email_from']}")
+
+ try:
+ body_html = template._render_field('body_html', [pum.id])[pum.id]
+
+ template.send_mail(pum.id, force_send=True, email_values=email_values)
+ _logger.info(f"[REMINDER] Email berhasil dikirim untuk PUM {pum.number}")
+
+ # Post info sederhana
+ pum.message_post(
+ body="Email Reminder Berhasil dikirimkan",
+ message_type="comment",
+ subtype_xmlid="mail.mt_note",
+ )
+
+ user_system = self.env['res.users'].browse(25)
+ system_id = user_system.partner_id.id if user_system else False
+
+ # Post isi email ke chatter
+ pum.message_post(
+ body=body_html,
+ message_type="comment",
+ subtype_xmlid="mail.mt_note",
+ author_id=system_id,
+ )
+ except Exception as e:
+ _logger.error(f"[REMINDER] Gagal mengirim email untuk PUM {pum.number}: {str(e)}")
+
+ return True
+
+
+ @api.depends('move_id.state')
+ def _compute_is_cab_visible(self):
+ for rec in self:
+ move = rec.move_id
+ rec.is_cab_visible = bool(move and move.state == 'posted')
+
+ def action_view_journal_uangmuka(self):
+ self.ensure_one()
+
+ ap_user_ids = [23, 9468]
+ # if self.env.user.id not in ap_user_ids:
+ # raise UserError('Hanya User AP yang dapat menggunakan fitur ini.')
+
+ if not self.move_id:
+ raise UserError("Journal Uang Muka belum tersedia.")
+
+ return {
+ 'name': 'Journal Entry',
+ 'view_mode': 'form',
+ 'res_model': 'account.move',
+ 'type': 'ir.actions.act_window',
+ 'res_id': self.move_id.id,
+ 'target': 'current',
+ }
+
+ @api.onchange('attachment_type')
+ def _onchange_attachment_type(self):
+ self.attachment_file_image = False
+ self.attachment_filename_image = False
+ self.attachment_file_pdf = False
+ self.attachment_filename_pdf = False
+
+ # Sales & MD : Darren ID 19
+ # Marketing : Iwan ID 216
+ # Logistic & Procurement : Rafly H ID 21
+ # FAT : Stephan ID 28
+ # HR & GA : Akbar ID 7 / Pimpinan
+ # ---------------------------------------
+ # AP : Manzila (Finance) ID 23
+
+ def _get_departement_approver(self):
+ mapping = {
+ 'sales': 19,
+ 'merchandiser': 19,
+ 'marketing': 216,
+ 'logistic': 21,
+ 'procurement': 21,
+ 'fat': 28,
+ 'hr_ga': 7,
+ }
+ return mapping.get(self.departement_type)
+
+ def action_realisasi_pum(self):
+ self.ensure_one()
+
+ realization = self.env['realization.down.payment'].search([('pum_id', '=', self.id)], limit=1)
+
+ if realization:
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': 'Realisasi PUM',
+ 'res_model': 'realization.down.payment',
+ 'view_mode': 'form',
+ 'target': 'current',
+ 'res_id': realization.id,
+ }
+ else:
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': 'Realisasi PUM',
+ 'res_model': 'realization.down.payment',
+ 'view_mode': 'form',
+ 'target': 'current',
+ 'context': {
+ 'default_pum_id': self.id,
+ 'default_value_down_payment': self.nominal,
+ 'default_name': f'Realisasi - {self.number or ""}',
+ 'default_pemberian_line_ids': [
+ (0, 0, {
+ 'date': self.create_date.date() if self.create_date else fields.Date.today(),
+ 'description': 'Uang Muka',
+ 'value': self.nominal
+ })
+ ]
+ }
+ }
+
+
+ def action_confirm_payment(self):
+ ap_user_ids = [23, 9468]
+ if self.env.user.id not in ap_user_ids:
+ raise UserError('Hanya User AP yang dapat menggunakan fitur ini.')
+
+ for rec in self:
+ if not rec.attachment_file_image and not rec.attachment_file_pdf:
+ raise UserError(
+ f'Tidak bisa konfirmasi pembayaran PUM {rec.name or ""} '
+ f'karena belum ada bukti attachment (PDF/Image).'
+ )
+
+ rec.status_pay_down_payment = 'payment'
+
+ rec.message_post(
+ body="Status pembayaran telah <b>dikonfirmasi</b> oleh <b>AP</b>.",
+ message_type="comment",
+ subtype_xmlid="mail.mt_note",
+ )
+
+
+
+ # def action_approval_check(self):
+ # for record in self:
+ # # user = record.user_id
+ # user = self.env['res.users'].browse(3401)
+ # roles = sorted(set(
+ # f"{group
+ # .name} (Category: {group.category_id.name})"
+ # for group in user.groups_id
+ # if group.category_id.name == 'Roles'
+ # ))
+ # _logger.info(f"[ROLE CHECK] User: {user.name} (Login: {user.login}) Roles: {roles}")
+ # return
+
+ def action_approval_check(self):
+ jakarta_tz = pytz.timezone('Asia/Jakarta')
+ now = datetime.now(jakarta_tz).replace(tzinfo=None)
+ formatted_date = now.strftime('%d %B %Y %H:%M')
+
+ for rec in self:
+ if not rec.departement_type:
+ raise UserError("Field 'departement_type' wajib diisi sebelum approval.")
+
+ approver_id = rec._get_departement_approver()
+
+ if rec.status == 'pengajuan1':
+ if self.env.user.id != approver_id:
+ raise UserError("Hanya approver departement yang berhak menyetujui tahap ini.")
+ rec.name_approval_departement = self.env.user.name
+ rec.approved_by = (rec.approved_by + ', ' if rec.approved_by else '') + rec.name_approval_departement
+ rec.date_approved_department = now
+
+ # Mapping posisi berdasarkan departement_type
+ department_titles = {
+ 'sales': 'Sales Manager',
+ 'merchandiser': 'Merchandiser Manager',
+ 'marketing': 'Marketing Manager',
+ 'logistic': 'Logistic Manager',
+ 'procurement': 'Procurement Manager',
+ 'fat': 'Finance & Accounting Manager',
+ 'hr_ga': 'HR & GA Manager',
+ }
+ rec.position_department = department_titles.get(rec.departement_type, 'Departement Manager')
+
+ rec.status = 'pengajuan2'
+
+ rec.message_post(
+ body=f"Approval <b>Departement</b> oleh <b>{self.env.user.name}</b> "
+ f"pada <i>{formatted_date}</i>."
+ )
+
+ elif rec.status == 'pengajuan2':
+ ap_user_ids = [23, 9468] # List user ID yang boleh approve sebagai Finance AP
+ if self.env.user.id not in ap_user_ids:
+ raise UserError("Hanya AP yang berhak menyetujui tahap ini.")
+ rec.name_approval_ap = self.env.user.name
+ rec.approved_by = (rec.approved_by + ', ' if rec.approved_by else '') + rec.name_approval_ap
+ rec.email_ap = self.env.user.email
+ rec.date_approved_ap = now
+ rec.position_ap = 'Finance AP'
+ rec.status = 'pengajuan3'
+
+ rec.message_post(
+ body=f"Approval <b>AP</b> oleh <b>{self.env.user.name}</b> "
+ f"pada <i>{formatted_date}</i>."
+ )
+
+ elif rec.status == 'pengajuan3':
+ if self.env.user.id != 7: # ID user Pimpinan
+ raise UserError("Hanya Pimpinan yang berhak menyetujui tahap ini.")
+ rec.name_approval_pimpinan = self.env.user.name
+ rec.approved_by = (rec.approved_by + ', ' if rec.approved_by else '') + rec.name_approval_pimpinan
+ rec.date_approved_pimpinan = now
+ rec.position_pimpinan = 'Pimpinan'
+ rec.status = 'approved'
+
+ rec.message_post(
+ body=f"Approval <b>Pimpinan</b> oleh <b>{self.env.user.name}</b> "
+ f"pada <i>{formatted_date}</i>."
+ )
+
+ else:
+ raise UserError("Status saat ini tidak bisa di-approve lagi.")
+
+ # rec.message_post(body=f"Approval oleh {self.env.user.name} pada tahap <b>{rec.status}</b>.")
+
+
+ def action_reject(self):
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': 'Alasan Penolakan',
+ 'res_model': 'reject.reason.downpayment',
+ 'view_mode': 'form',
+ 'target': 'new',
+ 'context': {'default_request_id': self.id},
+ }
+
+ def action_draft(self):
+ for record in self:
+ # Pastikan hanya yang statusnya 'reject' yang bisa di-reset
+ if record.status != 'reject':
+ raise UserError("Hanya data dengan status 'Reject' yang bisa dikembalikan ke Draft atau status sebelumnya.")
+
+ # Jika ada last_status, gunakan itu; jika tidak, fallback ke 'draft'
+ new_status = 'pengajuan1'
+
+ # Reset field-field approval & alasan reject
+ record.write({
+ 'status': new_status,
+ 'reason_reject': False,
+ 'last_status': False,
+ 'name_approval_departement': False,
+ 'name_approval_ap': False,
+ 'name_approval_pimpinan': False,
+ 'date_approved_department': False,
+ 'date_approved_ap': False,
+ 'date_approved_pimpinan': False,
+ 'position_department': False,
+ 'position_ap': False,
+ 'position_pimpinan': False,
+ })
+
+ record.message_post(body=f"Status dikembalikan ke <b>{new_status.capitalize()}</b> oleh {self.env.user.name}.")
+
+
+ def action_ap_only(self):
+ self.ensure_one()
+
+ ap_user_ids = [23, 9468] # Ganti sesuai kebutuhan
+ # if self.env.user.id not in ap_user_ids:
+ # raise UserError('Hanya User AP yang dapat menggunakan fitur ini.')
+
+ if self.move_id:
+ raise UserError('CAB / Jurnal sudah pernah dibuat untuk PUM ini.')
+
+ return {
+ 'name': 'Create CAB AP Only',
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'down.payment.ap.only',
+ 'view_mode': 'form',
+ 'target': 'new',
+ 'context': {
+ 'default_nominal': self.nominal,
+ 'default_down_payment_id': self.id,
+ }
+ }
+
+
+ @api.depends('date_back_to_office', 'status')
+ def _compute_days_remaining(self):
+ today = date.today()
+ for rec in self:
+ if rec.status in ['approved', 'reject'] and rec.days_remaining:
+ continue
+
+ if rec.date_back_to_office:
+ due_date = rec.date_back_to_office + timedelta(days=7)
+ rec.estimated_return_date = due_date
+
+ # Jika hari ini sebelum tanggal kembali, maka anggap belum mulai dihitung
+ effective_today = max(today, rec.date_back_to_office)
+ rec.days_remaining = (due_date - effective_today).days
+ else:
+ rec.estimated_return_date = False
+ rec.days_remaining = 0
+
+ @api.onchange('date_back_to_office')
+ def _onchange_date_back_to_office(self):
+ if self.date_back_to_office and self.date_back_to_office < date.today():
+ return {
+ 'warning': {
+ 'title': _('Tanggal Tidak Valid'),
+ 'message': _('Tanggal kembali ke kantor tidak boleh lebih awal dari hari ini.')
+ }
+ }
+
+ @api.onchange('applicant_name')
+ def _onchange_applicant_name(self):
+ if self.applicant_name:
+ self.account_name = self.applicant_name
+
+ @api.onchange('account_name')
+ def _onchange_account_name(self):
+ if self.account_name:
+ self.applicant_name = self.account_name
+
+ @api.onchange('user_id')
+ def _onchange_user_id_limit_check(self):
+ if not self.user_id:
+ return
+
+ pum_ids = self.search([
+ ('user_id', '=', self.user_id.id),
+ ('status', '!=', 'reject')
+ ])
+
+ active_pum_count = 0
+ for pum in pum_ids:
+ realization = self.env['realization.down.payment'].search([('pum_id', '=', pum.id)], limit=1)
+ if not realization or realization.done_status != 'done_not_realized':
+ active_pum_count += 1
+
+ if active_pum_count >= 2:
+ return {
+ 'warning': {
+ 'title': 'Batas Pengajuan Tercapai',
+ 'message': 'User ini sudah memiliki 2 PUM aktif. Tidak dapat mengajukan lagi sampai salah satu direalisasi.',
+ }
+ }
+
+ @api.model
+ def create(self, vals):
+ user = self.env.user
+
+ pum_ids = self.search([
+ ('user_id', '=', user.id),
+ ('status', '!=', 'reject')
+ ])
+
+ active_pum_count = 0
+ for pum in pum_ids:
+ realization = self.env['realization.down.payment'].search([('pum_id', '=', pum.id)], limit=1)
+ if not realization or realization.done_status != 'done_not_realized':
+ active_pum_count += 1
+
+ if active_pum_count >= 2:
+ raise UserError("Anda hanya dapat mengajukan maksimal 2 PUM aktif. Silakan realisasikan salah satunya terlebih dahulu.")
+
+ if not vals.get('number') or vals['number'] == 'New Draft':
+ vals['number'] = self.env['ir.sequence'].next_by_code('down.payment') or 'New Draft'
+
+ vals['status'] = 'pengajuan1'
+ return super(DownPayment, self).create(vals)
+
+
+class RealizationDownPaymentLine(models.Model):
+ _name = 'realization.down.payment.line'
+ _description = 'Rincian Pemberian PUM'
+
+ realization_id = fields.Many2one('realization.down.payment', string='Realization')
+ date = fields.Date(string='Tanggal', required=True, default=fields.Date.today)
+ description = fields.Char(string='Description', required=True)
+ value = fields.Float(string='Nilai', required=True)
+
+
+class RealizationDownPaymentUseLine(models.Model):
+ _name = 'realization.down.payment.use.line'
+ _description = 'Rincian Penggunaan PUM'
+
+ realization_id = fields.Many2one('realization.down.payment', string='Realization')
+ date = fields.Date(string='Tanggal', required=True, default=fields.Date.today)
+ description = fields.Char(string='Description', required=True)
+ nominal = fields.Float(string='Nominal', required=True)
+ done_attachment = fields.Boolean(string='Checked', default=False)
+
+ lot_of_attachment = fields.Selection(
+ related='realization_id.lot_of_attachment',
+ string='Lot of Attachment (Related)',
+ store=False
+ )
+
+ attachment_type = fields.Selection([
+ ('pdf', 'PDF'),
+ ('image', 'Image'),
+ ], string="Attachment Type", default='pdf')
+
+ attachment_file_image = fields.Binary(string='Attachment Image', attachment_filename='attachment_filename_image')
+ attachment_file_pdf = fields.Binary(string='Attachment PDF', attachment_filename='attachment_filename_pdf')
+ attachment_filename_image = fields.Char(string='Filename Image')
+ attachment_filename_pdf = fields.Char(string='Filename PDF')
+
+ account_id = fields.Many2one(
+ 'account.account', string='Jenis Biaya', required=True,
+ domain="[('id', 'in', [484, 486, 488, 506, 507, 625, 471, 519, 527, 528, 529, 530, 565])]" # ID Jenis Biaya yang dibutuhkan
+ )
+
+ @api.onchange('account_id')
+ def _onchange_account_id(self):
+ for rec in self:
+ if rec.account_id:
+ rec.description = rec.account_id.name + " - "
+
+ @api.onchange('attachment_type')
+ def _onchange_attachment_type(self):
+ self.attachment_file_image = False
+ self.attachment_filename_image = False
+ self.attachment_file_pdf = False
+ self.attachment_filename_pdf = False
+
+ @api.onchange('done_attachment')
+ def _onchange_done_attachment(self):
+ ap_user_ids = [23, 9468] # List user ID yang boleh approve sebagai Finance AP
+
+ if self.done_attachment and self.env.user.id not in ap_user_ids:
+ self.done_attachment = False
+ return {
+ 'warning': {
+ 'title': _('Tidak Diizinkan'),
+ 'message': _('Hanya user AP yang bisa mencentang Done Attachment.')
+ }
+ }
+
+ @api.onchange('nominal')
+ def _onchange_nominal_no_minus(self):
+ if self.nominal and self.nominal < 0:
+ self.nominal = 0
+ return {
+ 'warning': {
+ 'title': _('Nominal Tidak Valid'),
+ 'message': _(
+ "Nominal penggunaan PUM tidak boleh diisi minus.\n"
+ "Nilai di Set menjadi nol."
+ )
+ }
+ }
+
+class RealizationDownPayment(models.Model):
+ _name = 'realization.down.payment'
+ _description = 'Realization Down Payment Management'
+ _inherit = ['mail.thread']
+
+ pum_id = fields.Many2one('down.payment', string='No PUM')
+ name = fields.Char(string='Nama', readonly=True, tracking=3)
+ title = fields.Char(string='Judul', tracking=3)
+ goals = fields.Text(string='Tujuan', tracking=3)
+ related = fields.Char(string='Terkait', tracking=3)
+
+ pemberian_line_ids = fields.One2many(
+ 'realization.down.payment.line', 'realization_id', string='Rincian Pemberian'
+ )
+ penggunaan_line_ids = fields.One2many(
+ 'realization.down.payment.use.line', 'realization_id', string='Rincian Penggunaan'
+ )
+
+ grand_total = fields.Float(string='Grand Total Pemberian', tracking=3, compute='_compute_grand_total')
+ grand_total_use = fields.Float(string='Grand Total Penggunaan', tracking=3, compute='_compute_grand_total_use')
+ value_down_payment = fields.Float(string='PUM', tracking=3)
+ remaining_value = fields.Float(string='Sisa Uang PUM', tracking=3, compute='_compute_remaining_value')
+
+ note_approval = fields.Text(string='Note Persetujuan', tracking=3)
+
+ name_approval_departement = fields.Char(string='Approval Departement', tracking=True)
+ name_approval_ap = fields.Char(string='Approval AP', tracking=True)
+ name_approval_pimpinan = fields.Char(string='Approval Pimpinan', tracking=True)
+
+ date_approved_department = fields.Datetime(string="Date Approved Department")
+ date_approved_ap = fields.Datetime(string="Date Approved AP")
+ date_approved_pimpinan = fields.Datetime(string="Date Approved Pimpinan")
+
+ position_department = fields.Char(string='Position Departement', tracking=True)
+ position_ap = fields.Char(string='Position AP', tracking=True)
+ position_pimpinan = fields.Char(string='Position Pimpinan', tracking=True)
+
+ approved_by = fields.Char(string='Approved By', tracking=True, track_visibility='always')
+
+ status = fields.Selection([
+ ('pengajuan1', 'Menunggu Approval Departement'),
+ ('pengajuan2', 'Menunggu Pengecekan AP'),
+ ('pengajuan3', 'Menunggu Approval Pimpinan'),
+ ('approved', 'Approved'),
+ ], string='Status', default='pengajuan1', tracking=3, index=True, track_visibility='onchange')
+
+ done_status = fields.Selection([
+ ('remaining', 'Remaining'),
+ ('done_not_realized', 'Done Not Realized'),
+ ('done_realized', 'Done Realized')
+ ], string='Status Realisasi', tracking=3, default='remaining')
+
+ date_done_not_realized = fields.Date(string='Tanggal Done Not Realized', tracking=3)
+
+ currency_id = fields.Many2one(
+ 'res.currency', string='Currency',
+ default=lambda self: self.env.company.currency_id
+ )
+
+ attachment_file_image = fields.Binary(string='Attachment Image', attachment_filename='attachment_filename_image')
+ attachment_file_pdf = fields.Binary(string='Attachment PDF', attachment_filename='attachment_filename_pdf')
+ attachment_filename_image = fields.Char(string='Filename Image')
+ attachment_filename_pdf = fields.Char(string='Filename PDF')
+
+ attachment_type = fields.Selection([
+ ('pdf', 'PDF'),
+ ('image', 'Image'),
+ ], string="Attachment Type", default='pdf')
+
+ lot_of_attachment = fields.Selection([
+ ('one_for_all_line', '1 Attachment Untuk Semua Line Penggunaan PUM'),
+ ('one_for_one_line', '1 Attachment per 1 Line Penggunaan PUM'),
+ ], string = "Banyaknya Attachment", default='one_for_one_line')
+
+ move_id = fields.Many2one('account.move', string='Journal Entries', domain=[('move_type', '=', 'entry')])
+ is_cab_visible = fields.Boolean(string='Is Journal Uang Muka Visible', compute='_compute_is_cab_visible')
+
+ def action_toggle_check_attachment(self):
+ ap_user_ids = [23, 9468]
+ if self.env.user.id not in ap_user_ids:
+ raise UserError('Hanya User AP yang dapat menggunakan tombol ini.')
+
+ for rec in self:
+ if not rec.penggunaan_line_ids:
+ continue
+
+ if all(line.done_attachment for line in rec.penggunaan_line_ids):
+ for line in rec.penggunaan_line_ids:
+ line.done_attachment = False
+ else:
+ for line in rec.penggunaan_line_ids:
+ line.done_attachment = True
+
+ @api.onchange('lot_of_attachment')
+ def _onchange_lot_of_attachment(self):
+ if self.lot_of_attachment == 'one_for_all_line':
+ for line in self.penggunaan_line_ids:
+ line.attachment_file_pdf = False
+ line.attachment_file_image = False
+ line.attachment_filename_pdf = False
+ line.attachment_filename_image = False
+
+
+ @api.depends('move_id.state')
+ def _compute_is_cab_visible(self):
+ for rec in self:
+ move = rec.move_id
+ rec.is_cab_visible = bool(move and move.state == 'posted')
+
+ def action_view_journal_uangmuka(self):
+ self.ensure_one()
+
+ ap_user_ids = [23, 9468]
+ if self.env.user.id not in ap_user_ids:
+ raise UserError('Hanya User AP yang dapat menggunakan fitur ini.')
+
+ if not self.move_id:
+ raise UserError("Journal Uang Muka belum tersedia.")
+
+ return {
+ 'name': 'Journal Entry',
+ 'view_mode': 'form',
+ 'res_model': 'account.move',
+ 'type': 'ir.actions.act_window',
+ 'res_id': self.move_id.id,
+ 'target': 'current',
+ }
+
+
+ @api.onchange('attachment_type')
+ def _onchange_attachment_type(self):
+ self.attachment_file_image = False
+ self.attachment_filename_image = False
+ self.attachment_file_pdf = False
+ self.attachment_filename_pdf = False
+
+ @api.depends('pemberian_line_ids.value')
+ def _compute_grand_total(self):
+ for rec in self:
+ rec.grand_total = sum(line.value for line in rec.pemberian_line_ids)
+
+ @api.depends('penggunaan_line_ids.nominal')
+ def _compute_grand_total_use(self):
+ for rec in self:
+ rec.grand_total_use = sum(line.nominal for line in rec.penggunaan_line_ids)
+
+ @api.depends('grand_total', 'grand_total_use')
+ def _compute_remaining_value(self):
+ for rec in self:
+ rec.remaining_value = rec.value_down_payment - rec.grand_total_use
+
+ def action_validation(self):
+ self.ensure_one()
+
+ # Validasi hanya AP yang bisa validasi
+ ap_user_ids = [23, 9468] # List user ID yang boleh approve sebagai Finance AP
+ if self.env.user.id not in ap_user_ids:
+ raise UserError('Hanya AP yang dapat melakukan validasi realisasi.')
+
+ if self.done_status == 'remaining':
+ self.done_status = 'done_not_realized'
+ self.date_done_not_realized = fields.Date.today()
+ elif self.done_status == 'done_not_realized':
+ self.done_status = 'done_realized'
+ else:
+ raise UserError('Realisasi sudah berstatus Done Realized.')
+
+ # Opsional: Tambah log di chatter
+ self.message_post(body=f"Status realisasi diperbarui menjadi <b>{dict(self._fields['done_status'].selection).get(self.done_status)}</b> oleh {self.env.user.name}.")
+
+ def action_cab(self):
+ self.ensure_one()
+
+ ap_user_ids = [23, 9468] # List user ID yang boleh approve sebagai Finance AP
+ if self.env.user.id not in ap_user_ids:
+ raise UserError('Hanya User AP yang dapat menggunakan ini.')
+ if self.move_id:
+ raise UserError("CAB / Jurnal sudah pernah dibuat untuk Realisasi ini.")
+
+ if not self.pum_id or not self.pum_id.move_id:
+ raise UserError("PUM terkait atau CAB belum tersedia.")
+
+ partner_id = self.pum_id.user_id.partner_id.id
+ cab_move = self.pum_id.move_id
+
+ # Account Bank Intransit dari CAB:
+ bank_intransit_line = cab_move.line_ids.filtered(lambda l: l.account_id.id in [573, 389, 392])
+ if not bank_intransit_line:
+ raise UserError("Account Bank Intransit dengan tidak ditemukan di CAB terkait.")
+ account_sisa_pum = bank_intransit_line[0].account_id.id
+
+ # Account Uang Muka Operasional
+ account_uang_muka = 403
+
+ # Tanggal pakai create_date atau hari ini
+ account_date = self.date_done_not_realized or fields.Date.today()
+
+ ref_label = f"Realisasi {self.pum_id.number} Biaya {self.pum_id.detail_note} ({cab_move.name})"
+
+ label_sisa_pum = f"Sisa PUM {self.pum_id.detail_note} {self.pum_id.number} ({cab_move.name})"
+
+ lines = []
+
+ # Sisa PUM (Debit)
+ if self.remaining_value > 0:
+ lines.append((0, 0, {
+ 'account_id': account_sisa_pum,
+ 'partner_id': partner_id,
+ 'name': label_sisa_pum,
+ 'debit': self.remaining_value,
+ 'credit': 0,
+ }))
+
+ # Biaya Penggunaan (Debit)
+ total_biaya = 0
+ for line in self.penggunaan_line_ids:
+ lines.append((0, 0, {
+ 'account_id': line.account_id.id,
+ 'partner_id': partner_id,
+ 'name': f"{line.description} ({line.date})",
+ 'debit': line.nominal,
+ 'credit': 0,
+ }))
+ total_biaya += line.nominal
+
+ # Uang Muka Operasional (Credit)
+ total_credit = self.remaining_value + total_biaya
+ if total_credit > 0:
+ lines.append((0, 0, {
+ 'account_id': account_uang_muka,
+ 'partner_id': partner_id,
+ 'name': ref_label,
+ 'debit': 0,
+ 'credit': total_credit,
+ }))
+
+ move = self.env['account.move'].create({
+ 'ref': ref_label,
+ 'date': account_date,
+ 'journal_id': 11, # MISC
+ 'line_ids': lines,
+ })
+
+ # self.message_post(body=f"Jurnal CAB telah dibuat dengan nomor: <b>{move.name}</b>.")
+
+ self.move_id = move.id
+
+ return {
+ 'name': _('Journal Entry'),
+ 'view_mode': 'form',
+ 'res_model': 'account.move',
+ 'type': 'ir.actions.act_window',
+ 'res_id': move.id,
+ 'target': 'current',
+ }
+
+ def action_approval_check(self):
+ jakarta_tz = pytz.timezone('Asia/Jakarta')
+ now = datetime.now(jakarta_tz).replace(tzinfo=None)
+ formatted_date = now.strftime('%d %B %Y %H:%M')
+
+ for rec in self:
+ if not rec.pum_id.departement_type:
+ raise UserError("Field 'departement_type' wajib diisi sebelum approval.")
+
+ approver_id = rec.pum_id._get_departement_approver()
+
+ if rec.status == 'pengajuan1':
+ if self.env.user.id != approver_id:
+ raise UserError("Hanya approver departement yang berhak menyetujui tahap ini.")
+ rec.name_approval_departement = self.env.user.name
+ rec.approved_by = (rec.approved_by + ', ' if rec.approved_by else '') + rec.name_approval_departement
+ rec.date_approved_department = now
+
+ # Mapping posisi berdasarkan departement_type
+ department_titles = {
+ 'sales': 'Sales Manager',
+ 'merchandiser': 'Merchandiser Manager',
+ 'marketing': 'Marketing Manager',
+ 'logistic': 'Logistic Manager',
+ 'procurement': 'Procurement Manager',
+ 'fat': 'Finance & Accounting Manager',
+ 'hr_ga': 'HR & GA Manager',
+ }
+ rec.position_department = department_titles.get(rec.pum_id.departement_type, 'Departement Manager')
+
+ rec.status = 'pengajuan2'
+
+ rec.message_post(
+ body=f"Approval <b>Departement</b> oleh <b>{self.env.user.name}</b> "
+ f"pada <i>{formatted_date}</i>."
+ )
+
+ elif rec.status == 'pengajuan2':
+ ap_user_ids = [23, 9468] # List user ID yang boleh approve sebagai Finance AP
+ if self.env.user.id not in ap_user_ids:
+ raise UserError("Hanya AP yang berhak menyetujui tahap ini.")
+ rec.name_approval_ap = self.env.user.name
+ rec.approved_by = (rec.approved_by + ', ' if rec.approved_by else '') + rec.name_approval_ap
+ rec.date_approved_ap = now
+ rec.position_ap = 'Finance AP'
+ rec.status = 'pengajuan3'
+
+ rec.message_post(
+ body=f"Approval <b>AP</b> oleh <b>{self.env.user.name}</b> "
+ f"pada <i>{formatted_date}</i>."
+ )
+
+ elif rec.status == 'pengajuan3':
+ if self.env.user.id != 7: # ID user Pimpinan
+ raise UserError("Hanya Pimpinan yang berhak menyetujui tahap ini.")
+ rec.name_approval_pimpinan = self.env.user.name
+ rec.approved_by = (rec.approved_by + ', ' if rec.approved_by else '') + rec.name_approval_pimpinan
+ rec.date_approved_pimpinan = now
+ rec.position_pimpinan = 'Pimpinan'
+ rec.status = 'approved'
+ rec.done_status = 'done_not_realized' # Set status done untuk realisasi
+
+ rec.message_post(
+ body=f"Approval <b>Pimpinan</b> oleh <b>{self.env.user.name}</b> "
+ f"pada <i>{formatted_date}</i>."
+ )
+
+ else:
+ raise UserError("Status saat ini tidak bisa di-approve lagi.")
+
+ # rec.message_post(body=f"Approval oleh {self.env.user.name} pada tahap <b>{rec.status}</b>.")
+
+ def _check_remaining_value(self):
+ for rec in self:
+ # Cek sisa PUM
+ if rec.remaining_value < 0:
+ raise ValidationError(
+ "Sisa uang PUM tidak boleh kurang dari 0.\n"
+ "Jika ada penggunaan uang pribadi, maka ajukan dengan sistem reimburse."
+ )
+
+ @api.model
+ def create(self, vals):
+ rec = super().create(vals)
+ rec._check_remaining_value()
+ return rec
+
+ def write(self, vals):
+ res = super().write(vals)
+ self._check_remaining_value()
+ return res
+
+class RejectReasonDownPayment(models.TransientModel):
+ _name = 'reject.reason.downpayment'
+ _description = 'Wizard for Reject Reason Down Payment'
+
+ request_id = fields.Many2one('down.payment', string='Pengajuan PUM')
+ reason_reject = fields.Text(string='Alasan Penolakan', required=True)
+
+ def confirm_reject(self):
+ if self.request_id:
+ self.request_id.write({
+ 'status': 'reject',
+ 'last_status': self.request_id.status,
+ 'reason_reject': self.reason_reject,
+ })
+ return {'type': 'ir.actions.act_window_close'}
+
+class DownPaymentApOnly(models.TransientModel):
+ _name = 'down.payment.ap.only'
+ _description = 'Create CAB from Down Payment for AP Only'
+
+ down_payment_id = fields.Many2one('down.payment', string='Down Payment', required=True)
+ account_id = fields.Many2one(
+ 'account.account', string='Bank Intransit', required=True,
+ domain="[('id', 'in', [573, 389, 392])]" # ID Bank Intransit
+ )
+ nominal = fields.Float(string='Nominal', related='down_payment_id.nominal')
+
+ def action_create_cab(self):
+ self.ensure_one()
+
+ # if self.env.user.id != 23:
+ # raise UserError('Hanya AP yang dapat menggunakan ini.')
+
+ dp = self.down_payment_id
+ partner_id = dp.user_id.partner_id.id
+
+ ref_label = f'{dp.number} - Biaya {dp.detail_note or "-"}'
+
+ move = self.env['account.move'].create({
+ 'ref': ref_label,
+ 'date': fields.Date.context_today(self),
+ 'journal_id': 11, # Cash & Bank
+ 'line_ids': [
+ (0, 0, {
+ 'account_id': 403, # Uang Muka Operasional
+ 'partner_id': partner_id,
+ 'name': ref_label,
+ 'debit': dp.nominal,
+ 'credit': 0,
+ }),
+ (0, 0, {
+ 'account_id': self.account_id.id, # Bank Intransit yang dipilih
+ 'partner_id': partner_id,
+ 'name': ref_label,
+ 'debit': 0,
+ 'credit': dp.nominal,
+ })
+ ]
+ })
+
+ dp.move_id = move.id # jika ada field untuk menampung move_id
+
+ return {
+ 'name': _('Journal Entry'),
+ 'view_mode': 'form',
+ 'res_model': 'account.move',
+ 'type': 'ir.actions.act_window',
+ 'res_id': move.id,
+ 'target': 'current',
+ }
+
diff --git a/indoteknik_custom/models/letter_receivable.py b/indoteknik_custom/models/letter_receivable.py
new file mode 100644
index 00000000..79a4a3e0
--- /dev/null
+++ b/indoteknik_custom/models/letter_receivable.py
@@ -0,0 +1,390 @@
+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([
+ ('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", "Menunggu Approval"),
+ ("sent", "Approved & 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",
+ )
+
+ perihal_label = fields.Char(
+ compute="_compute_perihal_label", string="Perihal Label")
+
+ def _compute_perihal_label(self):
+ for rec in self:
+ rec.perihal_label = dict(self._fields['perihal'].selection).get(rec.perihal, '')
+
+ # 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):
+ wib = pytz.timezone('Asia/Jakarta')
+ now_wib = datetime.now(wib)
+
+ 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 == "waiting_approval":
+ if rec.perihal in ("sp1", "sp2", "sp3"):
+ if self.env.user.id not in pimpinan_user_ids:
+ raise UserError("Hanya Pimpinan yang berhak menyetujui surat peringatan piutang (SP1, SP2, SP3).")
+ rec.state = "sent"
+ now_utc = now_wib.astimezone(pytz.UTC).replace(tzinfo=None)
+ rec.send_date = now_utc
+ rec.action_send_letter()
+
+ 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_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"""
+ <tr>
+ <td>{line.invoice_number or '-'}</td>
+ <td>{self.partner_id.name or '-'}</td>
+ <td>{fields.Date.to_string(line.invoice_date) or '-'}</td>
+ <td>{fields.Date.to_string(line.invoice_date_due) or '-'}</td>
+ <td>{line.new_invoice_day_to_due}</td>
+ <td>{line.ref or '-'}</td>
+ <td>{formatLang(self.env, line.amount_residual, currency_obj=line.currency_id)}</td>
+ <td>{line.payment_term_id.name or '-'}</td>
+ </tr>
+ """
+
+ invoice_table_footer = f"""
+ <tfoot>
+ <tr style="font-weight:bold; background-color:#f9f9f9;">
+ <td colspan="6" align="right">Grand Total</td>
+ <td>{formatLang(self.env, grand_total, currency_obj=self.currency_id, monetary=True)}</td>
+ <td colspan="2"></td>
+ </tr>
+ </tfoot>
+ """
+ # inject table rows ke template
+ body_html = re.sub(
+ r"<tbody[^>]*>.*?</tbody>",
+ f"<tbody>{invoice_table_rows}</tbody>{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')
+ 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',
+ })
+
+ values = {
+ # 'subject': template.subject.replace('${object.name}', self.name or ''),
+ 'subject': perihal_map.get(self.perihal, self.perihal or '') + " - " + (self.partner_id.name or ''),
+ 'email_to': self.tujuan_email,
+ 'email_from': 'finance@indoteknik.co.id',
+ 'body_html': body_html,
+ 'attachments': [(attachment.name, attachment.datas)],
+ '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'
+ )
+ selected_invoice_id = self.env.context.get('default_selected_invoice_id')
+ 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': 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.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"] = "waiting_approval"
+ 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
diff --git a/indoteknik_custom/models/unpaid_invoice_view.py b/indoteknik_custom/models/unpaid_invoice_view.py
new file mode 100644
index 00000000..25e04968
--- /dev/null
+++ b/indoteknik_custom/models/unpaid_invoice_view.py
@@ -0,0 +1,48 @@
+from odoo import models, fields
+
+class UnpaidInvoiceView(models.Model):
+ _name = 'unpaid.invoice.view'
+ _description = 'Unpaid Invoices Monitoring'
+ _auto = False
+ _rec_name = 'partner_name'
+ _order = 'partner_name, new_invoice_day_to_due DESC'
+
+ partner_id = fields.Many2one('res.partner', string='Partner')
+ partner_name = fields.Char(string='Partner Name')
+ # email = fields.Char()
+ # phone = fields.Char()
+ invoice_id = fields.Many2one('account.move', string='Invoice')
+ invoice_number = fields.Char(string='Invoice Number')
+ invoice_date = fields.Date()
+ invoice_date_due = fields.Date(string='Due Date')
+ date_terima_tukar_faktur = fields.Date(string='Terima Faktur')
+ currency_id = fields.Many2one('res.currency', string='Currency')
+ amount_total = fields.Monetary(string='Total Amount', currency_field='currency_id')
+ amount_residual = fields.Monetary(string='Sisa Amount', currency_field='currency_id')
+ payment_state = fields.Selection([
+ ('not_paid', 'Not Paid'),
+ ('in_payment', 'In Payment'),
+ ('paid', 'Paid'),
+ ('partial', 'Partially Paid'),
+ ('reversed', 'Reversed')], string='Payment State')
+ payment_term_id = fields.Many2one('account.payment.term', string='Payment Term')
+ invoice_day_to_due = fields.Integer(string="Day to Due")
+ new_invoice_day_to_due = fields.Integer(string="New Day Due")
+
+ ref = fields.Char(string='Reference')
+ invoice_user_id = fields.Many2one('res.users', string='Salesperson')
+ date_kirim_tukar_faktur = fields.Date(string='Kirim Faktur')
+ sale_id = fields.Many2one('sale.order', string='Sale Order')
+
+ def action_create_surat_piutang(self):
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'surat.piutang',
+ 'view_mode': 'form',
+ 'target': 'current',
+ 'context': {
+ 'default_partner_id': self.partner_id.id,
+ 'default_selected_invoice_id': self.invoice_id.id,
+ }
+ }
diff --git a/indoteknik_custom/report/report_surat_piutang copy.xml b/indoteknik_custom/report/report_surat_piutang copy.xml
new file mode 100644
index 00000000..cb5762f3
--- /dev/null
+++ b/indoteknik_custom/report/report_surat_piutang copy.xml
@@ -0,0 +1,149 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+ <data>
+
+ <!-- External Layout tanpa company -->
+ <template id="external_layout_no_company">
+ <!-- HEADER -->
+ <div class="header">
+ <img t-att-src="'https://erp.indoteknik.com/api/image/ir.attachment/datas/2498521'"
+ class="img img-fluid w-100"/>
+ </div>
+
+ <!-- CONTENT -->
+ <div class="content mt-5 mb-5 ml-3 mr-3">
+ <t t-raw="0"/>
+ </div>
+
+ <!-- FOOTER -->
+ <div class="footer">
+ <img t-att-src="'https://erp.indoteknik.com/api/image/ir.attachment/datas/2498529'"
+ style="height:60px;"/>
+ </div>
+ </template>
+
+ <!-- Report Action -->
+ <record id="action_report_surat_piutang" model="ir.actions.report">
+ <field name="name">Surat Peringatan Piutang</field>
+ <field name="model">surat.piutang</field>
+ <field name="report_type">qweb-pdf</field>
+ <field name="report_name">indoteknik_custom.report_surat_piutang_formal_custom</field>
+ <field name="report_file">indoteknik_custom.report_surat_piutang_formal_custom</field>
+ <field name="binding_model_id" ref="model_surat_piutang"/>
+ <field name="binding_type">report</field>
+ </record>
+
+ <!-- QWeb Template Surat -->
+ <template id="report_surat_piutang_formal_custom">
+ <t t-call="indoteknik_custom.external_layout_no_company">
+ <t t-set="doc" t-value="docs[0] if docs else None"/>
+
+ <!-- SURAT CONTENT -->
+ <main class="o_report_layout_standard" style="font-size:12pt; font-family: Arial, sans-serif;">
+
+ <!-- Header Surat -->
+ <div class="row mb-3">
+ <div class="col-6">
+ Ref. No: <t t-esc="doc.name or '-'"/>
+ </div>
+ <div class="col-6 text-right">
+ Jakarta, <t t-esc="doc.send_date and doc.send_date.strftime('%d %B %Y') or '-'"/>
+ </div>
+ </div>
+
+ <!-- Tujuan -->
+ <div class="mb-3">
+ <strong>Kepada Yth.</strong><br/>
+ <t t-esc="doc.partner_id.name if doc and doc.partner_id else '-'"/><br/>
+ <t t-esc="doc.partner_id.street if doc and doc.partner_id else '-'"/><br/>
+ <t t-esc="doc.partner_id.country_id.name if doc and doc.partner_id and doc.partner_id.country_id else '-'"/>
+ </div>
+
+ <!-- UP & Perihal -->
+ <div class="mb-4">
+ U.P. : <t t-esc="doc.tujuan_nama or '-'"/><br/>
+ <strong>Perihal:</strong> <t t-esc="doc.perihal or '-'"/>
+ </div>
+
+ <!-- Isi Surat -->
+ <div class="mb-3">Dengan Hormat,</div>
+ <div class="mb-3">Yang bertanda tangan di bawah ini menyampaikan sebagai berikut:</div>
+
+ <div class="mb-3 text-justify">
+ Namun, bersama surat ini kami ingin mengingatkan bahwa hingga tanggal surat ini dibuat, masih terdapat tagihan yang belum diselesaikan oleh pihak
+ <t t-esc="doc.partner_id.name if doc and doc.partner_id else '-'"/> periode bulan <t t-esc="doc.periode_invoices_terpilih or '-'"/>, berdasarkan data korespondensi dan laporan keuangan yang kami kelola,
+ <t t-esc="doc.partner_id.name if doc and doc.partner_id else '-'"/> (“Saudara”) masih mempunyai tagihan yang telah jatuh tempo dan belum dibayarkan sejumlah
+ <t t-esc="doc.grand_total_text or '-'"/> (“Tagihan”).
+ </div>
+
+ <div class="mb-3">Berikut kami lampirkan Rincian Tagihan yang telah Jatuh Tempo:</div>
+
+ <!-- Tabel Invoice -->
+ <table class="table table-sm table-bordered mb-4">
+ <thead class="thead-light">
+ <tr>
+ <th>Invoice Number</th>
+ <th>Invoice Date</th>
+ <th>Due Date</th>
+ <th class="text-center">Day to Due</th>
+ <th>Reference</th>
+ <th class="text-right">Amount Due</th>
+ <th>Payment Terms</th>
+ </tr>
+ </thead>
+ <tbody>
+ <t t-foreach="doc.line_ids.filtered(lambda l: l.selected)" t-as="line">
+ <tr>
+ <td><t t-esc="line.invoice_number or '-'"/></td>
+ <td><t t-esc="line.invoice_date and line.invoice_date.strftime('%d-%m-%Y') or '-'"/></td>
+ <td><t t-esc="line.invoice_date_due and line.invoice_date_due.strftime('%d-%m-%Y') or '-'"/></td>
+ <td class="text-center"><t t-esc="line.new_invoice_day_to_due or '-'"/></td>
+ <td><t t-esc="line.ref or '-'"/></td>
+ <td class="text-right"><t t-esc="line.amount_residual or '-'"/></td>
+ <td><t t-esc="line.payment_term_id.name or '-'"/></td>
+ </tr>
+ </t>
+ </tbody>
+ <tfoot>
+ <tr class="font-weight-bold">
+ <td colspan="6" class="text-right">
+ GRAND TOTAL INVOICE YANG BELUM DIBAYAR DAN TELAH JATUH TEMPO
+ </td>
+ <td class="text-right">
+ <t t-esc="doc.grand_total or '-'"/> (<t t-esc="doc.grand_total_text or '-'"/>)
+ </td>
+ </tr>
+ </tfoot>
+ </table>
+
+ <!-- Isi Penutup -->
+ <div class="mb-3">
+ Kami belum menerima konfirmasi pelunasan ataupun pembayaran sebagian dari total kewajiban tersebut. Kami sangat terbuka untuk berdiskusi serta mencari solusi terbaik agar kerja sama tetap berjalan baik.
+ </div>
+
+ <div class="mb-3">
+ Oleh karena itu, kami mohon perhatian dan itikad baik dari pihak <t t-esc="doc.partner_id.name if doc and doc.partner_id else '-'"/> untuk segera melakukan pelunasan atau memberikan informasi terkait rencana pembayaran paling lambat dalam waktu 7 (tujuh) hari kerja sejak surat ini diterima.
+ </div>
+
+ <div class="mb-3">
+ Jika dalam waktu yang telah ditentukan belum ada penyelesaian atau tanggapan, kami akan mempertimbangkan untuk melanjutkan proses sesuai ketentuan yang berlaku.
+ </div>
+
+ <div class="mb-4">
+ Demikian kami sampaikan. Atas perhatian dan kerja samanya, kami ucapkan terima kasih.
+ </div>
+
+ <div class="mb-2">Hormat kami,</div>
+
+ <!-- TTD -->
+ <div class="mt-5">
+ <img t-att-src="'https://erp.indoteknik.com/api/image/ir.attachment/datas/2851919'" style="width:200px; height:auto;"/><br/>
+ <div>Nama: Akbar Prabawa<br/>Jabatan: General Manager</div>
+ </div>
+
+ </main>
+ </t>
+ </template>
+
+ </data>
+</odoo>
diff --git a/indoteknik_custom/report/report_surat_piutang.xml b/indoteknik_custom/report/report_surat_piutang.xml
new file mode 100644
index 00000000..770aa535
--- /dev/null
+++ b/indoteknik_custom/report/report_surat_piutang.xml
@@ -0,0 +1,241 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+ <data>
+
+ <!-- Report Action -->
+ <record id="action_report_surat_piutang" model="ir.actions.report">
+ <field name="name">Surat Peringatan Piutang</field>
+ <field name="model">surat.piutang</field>
+ <field name="report_type">qweb-pdf</field>
+ <field name="report_name">indoteknik_custom.report_surat_piutang</field>
+ <field name="report_file">indoteknik_custom.report_surat_piutang</field>
+ <field name="print_report_name">'%s - %s' % (object.perihal_label or '', object.partner_id.name or '')</field>
+ <field name="binding_model_id" ref="model_surat_piutang"/>
+ <field name="binding_type">report</field>
+ </record>
+
+ <template id="external_layout_surat_piutang">
+ <t t-call="web.html_container">
+
+ <!-- Header -->
+ <div class="header">
+ <img src="https://erp.indoteknik.com/api/image/ir.attachment/datas/2498521"
+ style="width:100%; display: block;"/>
+ </div>
+
+ <!-- Body -->
+ <div class="article" style="margin: 0 1.5cm 0 1.5cm; ">
+ <t t-raw="0"/>
+ </div>
+
+ <!-- Footer -->
+ <div class="footer">
+ <img src="https://erp.indoteknik.com/api/image/ir.attachment/datas/2859765"
+ style="width:100%; display: block;"/>
+ </div>
+ </t>
+ </template>
+
+
+
+ <!-- Wrapper Template -->
+ <template id="report_surat_piutang">
+ <t t-call="web.html_container">
+ <t t-foreach="docs" t-as="doc">
+ <t t-call="indoteknik_custom.report_surat_piutang_document"
+ t-lang="doc.partner_id.lang"/>
+ </t>
+ </t>
+ </template>
+
+ <!-- Document Template -->
+ <template id="report_surat_piutang_document">
+ <t t-call="indoteknik_custom.external_layout_surat_piutang">
+ <t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
+ <div class="page">
+
+ <!-- Ref & Date -->
+ <div class="row mb3">
+ <div class="col-6">
+ Ref. No: <t t-esc="doc.name or '-'"/>
+ </div>
+ <div class="col-6 text-right">
+ Jakarta, <t t-esc="doc.send_date and doc.send_date.strftime('%d %B %Y') or '-'"/>
+ </div>
+ </div>
+ <br/>
+ <!-- Tujuan -->
+ <div class="mb3" style="max-width:500px; word-wrap:break-word; white-space:normal;">
+ <strong>Kepada Yth.</strong><br/>
+ <strong><t t-esc="doc.partner_id.name or '-'"/></strong><br/>
+ <span style="display:inline-block; max-width:400px; word-wrap:break-word; white-space:normal;">
+ <t t-esc="doc.partner_id.street or ''"/>
+ </span><br/>
+ <u>Republik Indonesia</u>
+ </div>
+ <br/>
+
+ <!-- UP & Perihal -->
+ <table style="margin-left:2cm;">
+ <tr style="font-weight: bold;">
+ <td style="padding-right:10px;">U.P.</td>
+ <td>: <t t-esc="doc.tujuan_nama or '-'"/></td>
+ </tr>
+ <tr style="font-weight: bold;">
+ <td style="padding-right:10px;">Perihal</td>
+ <td>: <u><t t-esc="doc.perihal_label or '-'"/></u></td>
+ </tr>
+ </table>
+
+ <br/>
+
+ <!-- Isi Surat -->
+ <p><strong>Dengan Hormat,</strong></p>
+ <p>Yang bertanda tangan di bawah ini:</p>
+
+ <p class="text-justify">
+ <strong>PT. Indoteknik Dotcom Gemilang</strong>, suatu perseroan terbatas yang didirikan berdasarkan hukum Negara Republik
+ Indonesia, yang beralamat di Jalan Bandengan Utara 85A No. 8-9, RT.003/RW.016, Penjaringan, Penjaringan, Jakarta
+ Utara, DKI Jakarta 14440, Republik Indonesia, dalam hal ini diwakili secara sah oleh Akbar Prabawa selaku General
+ Manager, dengan ini menyampaikan sebagai berikut:
+ </p>
+
+ <p class="text-justify">
+ Kami mengucapkan terima kasih atas kerja sama yang telah terjalin dengan baik selama ini antara perusahaan kami
+ dengan <strong><t t-esc="doc.partner_id.name or '-'"/></strong>.
+ </p>
+
+ <p class="text-justify">
+ Namun, bersama surat ini kami ingin mengingatkan bahwa hingga tanggal surat ini dibuat, masih terdapat tagihan yang
+ belum diselesaikan oleh pihak <strong><t t-esc="doc.partner_id.name or '-'"/></strong> kepada kami periode bulan
+ <t t-esc="doc.periode_invoices_terpilih or '-'"/>, bahwa berdasarkan data korespondensi dan laporan keuangan yang kami kelola,
+ <t t-esc="doc.partner_id.name or '-'"/> <b>(“Saudara”)</b> masih mempunyai tagihan yang telah jatuh tempo dan belum dibayarkan sejumlah
+ <t t-esc="doc.grand_total_text or '-'"/> <b>(“Tagihan”)</b>.
+ </p>
+
+ <p>Berikut kami lampirkan Rincian Tagihan yang telah Jatuh Tempo:</p>
+
+ <!-- Tabel Invoice -->
+ <table class="table table-sm o_main_table"
+ style="font-size:13px; border:1px solid #000; border-collapse: collapse; width:100%; table-layout: fixed;">
+
+ <thead style="background:#f5f5f5;">
+ <tr>
+ <th style="border:1px solid #000; padding:4px; width:5%; font-weight: bold;" class="text-center">No.</th>
+ <th style="border:1px solid #000; padding:4px; width:15%; font-weight: bold;">Invoice Number</th>
+ <th style="border:1px solid #000; padding:4px; width:10%; font-weight: bold;">Invoice Date</th>
+ <th style="border:1px solid #000; padding:4px; width:10%; font-weight: bold;">Due Date</th>
+ <th style="border:1px solid #000; padding:4px; width:6%; font-weight: bold;" class="text-center">Day to Due</th>
+ <th style="border:1px solid #000; padding:4px; width:16%; font-weight: bold;">Reference</th>
+ <th style="border:1px solid #000; padding:4px; width:17%; font-weight: bold;" class="text-right">Amount Due</th>
+ <th style="border:1px solid #000; padding:4px; width:12%; font-weight: bold;">Payment Terms</th>
+ </tr>
+ </thead>
+
+ <tbody>
+ <tr t-foreach="doc.line_ids.filtered(lambda l: l.selected)" t-as="line">
+
+ <!-- Nomor Urut -->
+ <td style="border:1px solid #000; padding:4px; text-align:center;">
+ <t t-esc="line.sort or '-'"/>
+ </td>
+
+ <!-- Invoice Number -->
+ <td style="border:1px solid #000; padding:4px; word-wrap: break-word;">
+ <t t-esc="line.invoice_number or '-'"/>
+ </td>
+
+ <!-- Invoice Date -->
+ <td style="border:1px solid #000; padding:4px;">
+ <t t-esc="line.invoice_date and line.invoice_date.strftime('%d-%m-%Y') or '-'"/>
+ </td>
+
+ <!-- Due Date -->
+ <td style="border:1px solid #000; padding:4px;">
+ <t t-esc="line.invoice_date_due and line.invoice_date_due.strftime('%d-%m-%Y') or '-'"/>
+ </td>
+
+ <!-- Day to Due -->
+ <td style="border:1px solid #000; padding:4px; text-align:center;">
+ <t t-esc="line.new_invoice_day_to_due or '-'"/>
+ </td>
+
+ <!-- Reference -->
+ <td style="border:1px solid #000; padding:4px; word-wrap: break-word;">
+ <t t-esc="line.ref or '-'"/>
+ </td>
+
+ <!-- Amount Due -->
+ <td style="border:1px solid #000; padding:4px; text-align:right;">
+ Rp. <t t-esc="'{:,.0f}'.format(line.amount_residual).replace(',', '.')"/>
+ </td>
+
+ <!-- Payment Terms -->
+ <td style="border:1px solid #000; padding:4px; word-wrap: break-word;">
+ <t t-esc="line.payment_term_id.name or '-'"/>
+ </td>
+ </tr>
+ <tr>
+ <td colspan="5" class="text-left" style="border:1px solid #000; padding:4px; word-wrap: break-word; white-space: normal; font-weight: bold;">
+ GRAND TOTAL INVOICE YANG BELUM DIBAYAR DAN TELAH JATUH TEMPO
+ </td>
+ <td colspan="3" class="text-right" style="border:1px solid #000; padding:4px; word-wrap: break-word; white-space: normal; font-weight: bold;">
+ Rp. <t t-esc="'{:,.0f}'.format(doc.grand_total).replace(',', '.')"/>
+ (<t t-esc="doc.grand_total_text or '-'"/>)
+ </td>
+ </tr>
+ </tbody>
+ </table>
+
+
+ <!-- Penutup -->
+ <p class="text-justify">
+ Kami belum menerima konfirmasi pelunasan ataupun pembayaran sebagian dari total kewajiban tersebut dan kami
+ memahami bahwa setiap perusahaan bisa saja menghadapi kendala operasional maupun keuangan, dan kami sangat
+ terbuka untuk berdiskusi serta mencari solusi terbaik bersama agar kerja sama kita tetap berjalan baik ke depannya.
+ </p>
+
+ <p class="text-justify">
+ Oleh karena itu, kami mohon perhatian dan itikad baik dari pihak <strong><t t-esc="doc.partner_id.name or '-'"/></strong>
+ untuk segera melakukan pelunasan atau memberikan informasi terkait rencana pembayaran paling lambat dalam waktu 7 (tujuh) hari kerja sejak surat ini diterima.
+ </p>
+
+ <p class="text-justify">
+ Jika dalam waktu yang telah ditentukan belum ada penyelesaian atau tanggapan, kami akan mempertimbangkan untuk
+ melanjutkan proses sesuai ketentuan yang berlaku.
+ </p>
+
+ <p class="text-justify">
+ Demikian kami sampaikan. Atas perhatian dan kerja samanya, kami ucapkan terima kasih.
+ </p>
+ <div class="mt32">
+ <p>Hormat kami,<br/>
+ <strong>PT. Indoteknik Dotcom Gemilang</strong>
+ </p>
+
+ <div style="height:120px; position: relative;">
+ <t t-if="doc.perihal != 'penagihan'">
+ <img src="https://erp.indoteknik.com/api/image/ir.attachment/datas/2851919"
+ style="width:300px; height:auto; margin-top:-40px;"/>
+ </t>
+ <t t-else="">
+ <div style="height:100px;"></div>
+ </t>
+ </div>
+ <table style="margin-top:10px;">
+ <tr style="border-top:1px solid #000; font-weight: bold;">
+ <td style="padding-right:50px; white-space: nowrap;">Nama</td>
+ <td>: Akbar Prabawa</td>
+ </tr>
+ <tr style="font-weight: bold;">
+ <td style="padding-right:50px; white-space: nowrap;">Jabatan</td>
+ <td>: General Manager</td>
+ </tr>
+ </table>
+ </div>
+ </div>
+ </t>
+ </template>
+
+ </data>
+</odoo>
diff --git a/indoteknik_custom/security/ir.model.access.csv b/indoteknik_custom/security/ir.model.access.csv
index 3a320510..25273893 100755
--- a/indoteknik_custom/security/ir.model.access.csv
+++ b/indoteknik_custom/security/ir.model.access.csv
@@ -195,4 +195,7 @@ access_tukar_guling_mapping_koli_all_users,tukar.guling.mapping.koli.all.users,m
access_purchase_order_update_date_wizard,access.purchase.order.update.date.wizard,model_purchase_order_update_date_wizard,base.group_user,1,1,1,1
access_sync_promise_date_wizard,access.sync.promise.date.wizard,model_sync_promise_date_wizard,base.group_user,1,1,1,1
access_sync_promise_date_wizard_line,access.sync.promise.date.wizard.line,model_sync_promise_date_wizard_line,base.group_user,1,1,1,1
-access_change_date_planned_wizard,access.change.date.planned.wizard,model_change_date_planned_wizard,,1,1,1,1 \ No newline at end of file
+access_change_date_planned_wizard,access.change.date.planned.wizard,model_change_date_planned_wizard,,1,1,1,1
+access_unpaid_invoice_view,access.unpaid.invoice.view,model_unpaid_invoice_view,base.group_user,1,1,1,1
+access_surat_piutang_user,surat.piutang user,model_surat_piutang,base.group_user,1,1,1,1
+access_surat_piutang_line_user,surat.piutang.line user,model_surat_piutang_line,base.group_user,1,1,1,1 \ No newline at end of file
diff --git a/indoteknik_custom/views/ir_sequence.xml b/indoteknik_custom/views/ir_sequence.xml
index 94c2cd07..4b8fec53 100644
--- a/indoteknik_custom/views/ir_sequence.xml
+++ b/indoteknik_custom/views/ir_sequence.xml
@@ -228,5 +228,14 @@
<field name="number_increment">1</field>
<field name="active">True</field>
</record>
+
+ <record id="seq_surat_piutang" model="ir.sequence">
+ <field name="name">Surat Piutang</field>
+ <field name="code">surat.piutang</field>
+ <field name="prefix"></field> <!-- format manual di model -->
+ <field name="padding">3</field>
+ <field name="number_next">1</field>
+ <field name="number_increment">1</field>
+ </record>
</data>
</odoo> \ No newline at end of file
diff --git a/indoteknik_custom/views/letter_receivable.xml b/indoteknik_custom/views/letter_receivable.xml
new file mode 100644
index 00000000..3a7c2c65
--- /dev/null
+++ b/indoteknik_custom/views/letter_receivable.xml
@@ -0,0 +1,148 @@
+<odoo>
+ <!-- Tree View -->
+ <record id="view_surat_piutang_tree" model="ir.ui.view">
+ <field name="name">surat.piutang.tree</field>
+ <field name="model">surat.piutang</field>
+ <field name="arch" type="xml">
+ <tree string="Surat Piutang">
+ <field name="name"/>
+ <field name="partner_id"/>
+ <field name="perihal"/>
+ <field name="state" widget="badge"
+ decoration-danger="state == 'draft'"
+ decoration-warning="state == 'waiting_approval'"
+ decoration-success="state == 'sent'"/>
+ <field name="send_date"/>
+ <!-- <field name="line_ids" widget="one2many_list"/> -->
+ </tree>
+ </field>
+ </record>
+
+ <!-- Form View -->
+ <record id="view_surat_piutang_form" model="ir.ui.view">
+ <field name="name">surat.piutang.form</field>
+ <field name="model">surat.piutang</field>
+ <field name="arch" type="xml">
+ <form string="Surat Piutang">
+ <header>
+ <field name="state" widget="statusbar" statusbar_visible="draft,waiting_approval,sent"/>
+ <button name="action_approve" type="object" string="Approve &amp; Send" class="btn-primary"/>
+ <!-- <button name="action_send_letter" type="object" string="Email Send" class="btn-primary"/> -->
+ </header>
+ <div class="alert alert-info"
+ role="alert"
+ style="height: 40px; margin-bottom:0px;"
+ attrs="{'invisible':[('state', '!=', 'draft')]}">
+ Selamat Datang di form Pengajuan Surat Piutang, Pastikan data sudah benar sebelum mengajukan approval.
+ </div>
+ <div class="alert alert-info"
+ role="alert"
+ style="height: 40px; margin-bottom:0px;"
+ attrs="{'invisible':[('state', '!=', 'waiting_approval')]}">
+ <strong>Info!</strong> Surat piutang telah diajukan &amp; surat otomatis terkirim bila telah di Approved (khusus SP1,SP2,SP3 yang bisa approved hanya pimpinan).
+ </div>
+ <div class="alert alert-success"
+ role="alert"
+ style="height: 40px; margin-bottom:0px;"
+ attrs="{'invisible':[('state', '!=', 'sent')]}">
+ Surat Piutang berhasil terkirim.
+ </div>
+ <sheet>
+ <div class="oe_title">
+ <h1>
+ <field name="name" readonly="1"/>
+ </h1>
+ </div>
+ <group>
+ <field name="tujuan_nama"/>
+ <field name="tujuan_email"/>
+ <field name="perihal"/>
+ <field name="partner_id" options="{'no_create': True}"/>
+ <field name="send_date" readonly="1"/>
+ </group>
+ <group>
+ <button name="action_refresh_lines"
+ string="Refresh Invoices"
+ type="object"
+ class="btn-primary"
+ help="Refresh Invoices agar data tetap update"/>
+ </group>
+ <notebook>
+ <page string="Invoice Lines">
+ <div class="alert alert-info"
+ role="alert"
+ style="height: 40px; margin-bottom:0px;">
+ <strong>Info!</strong> Hanya invoice yang dipilih (tercentang) akan disertakan dalam dokumen surat piutang.
+ </div>
+ <div style="margin-bottom:4px; margin-top:4px;">
+ <strong>Grand Total Invoice Terpilih:<br/>Rp.
+ <field name="grand_total"/> (
+ <field name="grand_total_text"/>
+ )
+ </strong>
+ </div>
+ <div style="margin-bottom:4px; margin-top:4px;">
+ <strong>Periode Invoices Terpilih:
+ <field name="periode_invoices_terpilih"/>
+ </strong>
+ </div>
+ <field name="line_ids">
+ <tree editable="bottom" create="false" delete="false">
+ <field name="selected"/>
+ <field name="invoice_id" readonly="1" optional="hide" force_save="1"/>
+ <field name="invoice_number" readonly="1" force_save="1"/>
+ <field name="ref" readonly="1" force_save="1"/>
+ <field name="invoice_date" readonly="1" force_save="1"/>
+ <field name="invoice_date_due" readonly="1" force_save="1"/>
+ <field name="invoice_day_to_due" readonly="1" force_save="1"/>
+ <field name="new_invoice_day_to_due" readonly="1" force_save="1"/>
+ <field name="amount_residual" readonly="1" force_save="1" sum="Grand Total"/>
+ <field name="currency_id" readonly="1" optional="hide" force_save="1"/>
+ <field name="payment_term_id" readonly="1" force_save="1"/>
+ <field name="date_kirim_tukar_faktur" readonly="1" optional="hide" force_save="1"/>
+ <field name="date_terima_tukar_faktur" readonly="1" optional="hide" force_save="1"/>
+ <field name="invoice_user_id" readonly="1" optional="hide" force_save="1"/>
+ <field name="sale_id" readonly="1" optional="hide" force_save="1"/>
+ </tree>
+ </field>
+ </page>
+ </notebook>
+ <div style="margin-top:4px; font-size:13px; color:#444; line-height:1.5;">
+ Surat piutang akan diterbitkan berdasarkan lama keterlambatan pembayaran.<br/>
+ Pilih invoice yang sesuai dengan kriteria berikut:
+ <ul style="margin:4px 0 0 18px;">
+ <li>Keterlambatan ≥ 45 hari → <em>Surat Resmi Penagihan</em></li>
+ <li>Keterlambatan ≥ 60 hari → <em>Surat Peringatan Piutang ke-1</em></li>
+ <li>Keterlambatan ≥ 70 hari → <em>Surat Peringatan Piutang ke-2</em></li>
+ <li>Keterlambatan ≥ 80 hari → <em>Surat Peringatan Piutang ke-3</em></li>
+ </ul>
+ </div>
+ </sheet>
+ <div class="oe_chatter">
+ <field name="message_follower_ids" widget="mail_followers"/>
+ <field name="message_ids" widget="mail_thread"/>
+ </div>
+ </form>
+ </field>
+ </record>
+
+ <!-- Menu -->
+ <record id="menu_surat_piutang_root" model="ir.ui.menu">
+ <field name="name">Surat Piutang</field>
+ <field name="parent_id" ref="account.menu_finance"/>
+ <field name="sequence" eval="10"/>
+ </record>
+
+ <record id="action_surat_piutang" model="ir.actions.act_window">
+ <field name="name">Surat Piutang</field>
+ <field name="res_model">surat.piutang</field>
+ <field name="view_mode">tree,form</field>
+ <field name="view_id" ref="view_surat_piutang_tree"/>
+ </record>
+
+ <menuitem id="menu_surat_piutang"
+ name="Surat Piutang"
+ parent="account.menu_finance_receivables"
+ action="action_surat_piutang"
+ sequence="1"/>
+</odoo>
diff --git a/indoteknik_custom/views/letter_receivable_mail_template.xml b/indoteknik_custom/views/letter_receivable_mail_template.xml
new file mode 100644
index 00000000..fa0fbc86
--- /dev/null
+++ b/indoteknik_custom/views/letter_receivable_mail_template.xml
@@ -0,0 +1,77 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+ <data noupdate="0">
+ <record id="letter_receivable_mail_template" model="mail.template">
+ <field name="name">Surat Piutang Invoices</field>
+ <field name="model_id" ref="indoteknik_custom.model_surat_piutang"/>
+ <field name="subject"></field>
+ <field name="email_from">finance@indoteknik.co.id</field>
+ <field name="email_to"></field>
+ <field name="body_html" type="html">
+ <div style="font-family:Arial, sans-serif; font-size:13px; color:#333;">
+ <div><b>Dengan hormat,</b></div>
+ <br/>
+ <div>Kepada Yth.</div>
+ <div><b>Manajemen ${object.partner_id.name}</b></div>
+ <br/>
+ <div>
+ Melalui email ini, kami ingin mengingatkan kembali terkait kewajiban pembayaran
+ ${object.partner_id.name} atas transaksi dengan rincian sebagai berikut:
+ </div>
+ <br/>
+
+ <table cellpadding="6" cellspacing="0" width="100%"
+ style="border-collapse:collapse; font-size:12px; border:1px solid #ddd;">
+ <thead>
+ <tr style="background-color:#f2f2f2; text-align:left;">
+ <th style="border:1px solid #ddd;">Invoice Number</th>
+ <th style="border:1px solid #ddd;">Customer</th>
+ <th style="border:1px solid #ddd;">Invoice Date</th>
+ <th style="border:1px solid #ddd;">Due Date</th>
+ <th style="border:1px solid #ddd;">Days To Due</th>
+ <th style="border:1px solid #ddd;">Reference</th>
+ <th style="border:1px solid #ddd;">Amount Due Signed</th>
+ <th style="border:1px solid #ddd;">Payment Terms</th>
+ </tr>
+ </thead>
+ <tbody>
+ <!-- baris invoice akan diinject dari Python -->
+ </tbody>
+ </table>
+
+ <p>
+ Hingga saat ini, kami belum menerima pembayaran atas tagihan tersebut.
+ Mohon konfirmasi dan tindak lanjut dari pihak saudara paling lambat pada
+ tanggal <b>${object.seven_days_after_sent_date}</b> (7 hari setelah email ini dikirimkan).
+ </p>
+
+ <p>
+ Sebagai informasi, kami lampirkan <b>${object.perihal}</b> untuk menjadi perhatian.
+ Jika tidak ada tanggapan atau penyelesaian dalam batas waktu tersebut, kami akan
+ melanjutkan dengan pengiriman surat peringatan berikutnya dan mengambil langkah-langkah
+ penyelesaian sesuai ketentuan yang berlaku.
+ </p>
+
+ <p>
+ Demikian kami sampaikan. Atas perhatian dan kerja samanya, kami ucapkan terima kasih.
+ </p>
+
+ <br/><br/>
+ <p>
+ <b>
+ Best Regards,<br/><br/>
+ Widya R.<br/>
+ Dept. Finance<br/>
+ PT. INDOTEKNIK DOTCOM GEMILANG<br/>
+ <img src="https://erp.indoteknik.com/api/image/ir.attachment/datas/2135765"
+ alt="Indoteknik" style="max-width:18%; height:auto;"/><br/>
+ <a href="https://wa.me/6285716970374" target="_blank">+62-857-1697-0374</a> |
+ <a href="mailto:finance@indoteknik.co.id">finance@indoteknik.co.id</a>
+ </b>
+ </p>
+ </div>
+ </field>
+ <field name="auto_delete" eval="True"/>
+ </record>
+ </data>
+</odoo>
diff --git a/indoteknik_custom/views/unpaid_invoice_view.xml b/indoteknik_custom/views/unpaid_invoice_view.xml
new file mode 100644
index 00000000..20145a46
--- /dev/null
+++ b/indoteknik_custom/views/unpaid_invoice_view.xml
@@ -0,0 +1,92 @@
+<odoo>
+ <!-- Tree view -->
+ <record id="view_unpaid_invoice_tree" model="ir.ui.view">
+ <field name="name">unpaid.invoice.view.tree</field>
+ <field name="model">unpaid.invoice.view</field>
+ <field name="arch" type="xml">
+ <tree string="Unpaid Invoices Monitoring" create="false" delete="false" edit="false">
+ <field name="partner_id"/>
+ <field name="invoice_number"/>
+ <field name="ref"/>
+ <field name="invoice_date"/>
+ <field name="date_kirim_tukar_faktur"/>
+ <field name="date_terima_tukar_faktur"/>
+ <field name="payment_term_id"/>
+ <field name="invoice_date_due" widget="badge" decoration-danger="invoice_day_to_due &lt; 0"/>
+ <field name="invoice_day_to_due" readonly="1"/>
+ <field name="new_invoice_day_to_due" readonly="1"/>
+ <field name="amount_total"/>
+ <field name="amount_residual"/>
+ <field name="payment_state" widget="badge"
+ decoration-danger="payment_state == 'not_paid'"
+ decoration-warning="payment_state == 'partial'"/>
+ <field name="invoice_user_id"/>
+ </tree>
+ </field>
+ </record>
+
+ <!-- Form view -->
+ <record id="view_unpaid_invoice_form" model="ir.ui.view">
+ <field name="name">unpaid.invoice.view.form</field>
+ <field name="model">unpaid.invoice.view</field>
+ <field name="arch" type="xml">
+ <form string="Unpaid Invoice Detail" create="false" edit="false" delete="false">
+ <sheet>
+ <group>
+ <group>
+ <field name="partner_id"/>
+ <field name="invoice_id"/>
+ <field name="ref"/>
+ <field name="invoice_date"/>
+ <field name="invoice_date_due" widget="badge" decoration-danger="invoice_day_to_due &lt; 0"/>
+ <field name="date_kirim_tukar_faktur"/>
+ <field name="date_terima_tukar_faktur"/>
+ <field name="payment_term_id"/>
+ </group>
+ <group>
+ <field name="sale_id"/>
+ <field name="invoice_user_id"/>
+ <field name="invoice_day_to_due"/>
+ <field name="new_invoice_day_to_due"/>
+ <field name="payment_state" widget="badge"
+ decoration-danger="payment_state == 'not_paid'"
+ decoration-warning="payment_state == 'partial'"/>
+ <field name="amount_total"/>
+ <field name="amount_residual"/>
+ <button name="action_create_surat_piutang"
+ type="object"
+ string="Create Surat Piutang"
+ class="oe_highlight"/>
+ </group>
+ </group>
+ </sheet>
+ </form>
+ </field>
+ </record>
+
+ <record id="view_unpaid_invoice_search" model="ir.ui.view">
+ <field name="name">unpaid.invoice.view.search</field>
+ <field name="model">unpaid.invoice.view</field>
+ <field name="arch" type="xml">
+ <search string="Search Unpaid Invoices">
+ <field name="partner_id"/>
+ <field name="invoice_number"/>
+ </search>
+ </field>
+ </record>
+
+ <!-- Action -->
+ <record id="action_unpaid_invoice_view" model="ir.actions.act_window">
+ <field name="name">Unpaid Invoices Monitoring</field>
+ <field name="res_model">unpaid.invoice.view</field>
+ <field name="view_mode">tree,form</field>
+ <field name="view_id" ref="view_unpaid_invoice_tree"/>
+ <field name="search_view_id" ref="view_unpaid_invoice_search"/>
+ </record>
+
+ <!-- Menu -->
+ <menuitem id="menu_unpaid_invoice_root"
+ name="Unpaid Invoices Monitoring"
+ parent="account.menu_finance_receivables"
+ action="action_unpaid_invoice_view"/>
+</odoo>