diff options
| -rwxr-xr-x | indoteknik_custom/__manifest__.py | 4 | ||||
| -rwxr-xr-x | indoteknik_custom/models/__init__.py | 4 | ||||
| -rw-r--r-- | indoteknik_custom/models/account_move.py | 2 | ||||
| -rw-r--r-- | indoteknik_custom/models/down_payment.py | 1088 | ||||
| -rw-r--r-- | indoteknik_custom/models/letter_receivable.py | 390 | ||||
| -rw-r--r-- | indoteknik_custom/models/unpaid_invoice_view.py | 48 | ||||
| -rw-r--r-- | indoteknik_custom/report/report_surat_piutang copy.xml | 149 | ||||
| -rw-r--r-- | indoteknik_custom/report/report_surat_piutang.xml | 241 | ||||
| -rwxr-xr-x | indoteknik_custom/security/ir.model.access.csv | 5 | ||||
| -rw-r--r-- | indoteknik_custom/views/ir_sequence.xml | 9 | ||||
| -rw-r--r-- | indoteknik_custom/views/letter_receivable.xml | 148 | ||||
| -rw-r--r-- | indoteknik_custom/views/letter_receivable_mail_template.xml | 77 | ||||
| -rw-r--r-- | indoteknik_custom/views/unpaid_invoice_view.xml | 92 |
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 & 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 & 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 < 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 < 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> |
