from odoo import fields, models, api, _ from datetime import date, datetime from terbilang import Terbilang from odoo.exceptions import UserError, ValidationError from markupsafe import escape as html_escape import pytz from lxml import etree class RefundSaleOrder(models.Model): _name = 'refund.sale.order' _description = 'Refund Sales Order' _inherit = ['mail.thread'] _rec_name = 'name' name = fields.Char(string='Refund Number', default='New', copy=False, readonly=True) note_refund = fields.Text(string='Note Refund') sale_order_ids = fields.Many2many('sale.order', string='Sales Order Numbers') uang_masuk = fields.Float(string='Uang Masuk', required=True) total_invoice = fields.Float(string='Total Invoice') ongkir = fields.Float(string='Ongkir', required=True, default=0.0) amount_refund = fields.Float(string='Total Refund', required=True) amount_refund_text = fields.Char(string='Total Refund Text', compute='_compute_refund_text') user_ids = fields.Many2many('res.users', string='Salespersons', compute='_compute_user_ids', domain=[('active', 'in', [True, False])]) create_uid = fields.Many2one('res.users', string='Created By', readonly=True) created_date = fields.Date(string='Tanggal Request Refund', readonly=True) status = fields.Selection([ ('draft', 'Draft'), ('pengajuan1', 'Approval Sales Manager'), ('pengajuan2', 'Approval AR'), ('pengajuan3', 'Approval Pimpinan'), ('reject', 'Cancel'), ('refund', 'Approved') ], string='Status Refund', default='draft', tracking=True) status_payment = fields.Selection([ ('pending', 'Pending'), ('reject', 'Cancel'), ('done', 'Payment') ], string='Status Payment', default='pending', tracking=True) reason_reject = fields.Text(string='Reason Cancel') refund_date = fields.Date(string='Tanggal Refund') invoice_ids = fields.Many2many('account.move', string='Invoices') bank = fields.Char(string='Bank', required=True) account_name = fields.Char(string='Account Name', required=True) account_no = fields.Char(string='Account No', required=True) finance_note = fields.Text(string='Finance Note') invoice_names = fields.Html(string="Group Invoice Number", compute="_compute_invoice_names") so_names = fields.Html(string="Group SO Number", compute="_compute_so_names") refund_type = fields.Selection([ ('barang_kosong_sebagian', 'Refund Barang Kosong Sebagian'), ('barang_kosong', 'Refund Barang Kosong Full'), ('uang', 'Refund Lebih Bayar'), ('retur_half', 'Refund Retur Sebagian'), ('retur', 'Refund Retur Full'), ('lainnya', 'Lainnya') ], string='Refund Type', required=True) refund_type_display = fields.Char(string="Refund Type Label", compute="_compute_refund_type_display") line_ids = fields.One2many('refund.sale.order.line', 'refund_id', string='Refund Lines') invoice_line_ids = fields.One2many( comodel_name='account.move.line', inverse_name='move_id', string='Invoice Lines', compute='_compute_invoice_lines' ) approved_by = fields.Text(string='Approved By', readonly=True) date_approved_sales = fields.Datetime(string='Date Approved (Sales Manager)', readonly=True) date_approved_ar = fields.Datetime(string='Date Approved (AR)', readonly=True) date_approved_pimpinan = fields.Datetime(string='Date Approved (Pimpinan)', readonly=True) position_sales = fields.Char(string='Position Sales', readonly=True) position_ar = fields.Char(string='Position AR', readonly=True) position_pimpinan = fields.Char(string='Position Pimpinan', readonly=True) partner_id = fields.Many2one( 'res.partner', string='Customer', required=True ) advance_move_names = fields.Html(string="Group Journal SO", compute="_compute_advance_move_names") uang_masuk_type = fields.Selection([ ('pdf', 'PDF'), ('image', 'Image'), ], string="Attachment Type", default='image') bukti_refund_type = fields.Selection([ ('pdf', 'PDF'), ('image', 'Image'), ], string="Attachment Type", default='image') bukti_uang_masuk_image = fields.Binary(string="Upload Bukti Uang Masuk") bukti_transfer_refund_image = fields.Binary(string="Upload Bukti Transfer Refund") bukti_uang_masuk_pdf = fields.Binary(string="Upload Bukti Uang Masuk") bukti_transfer_refund_pdf = fields.Binary(string="Upload Bukti Transfer Refund") journal_refund_move_id = fields.Many2one( 'account.move', string='Journal Refund', compute='_compute_journal_refund_move_id', ) journal_refund_state = fields.Selection( related='journal_refund_move_id.state', string='Journal Refund State', ) is_locked = fields.Boolean(string="Locked", compute="_compute_is_locked") sale_order_names_jasper = fields.Char(string='Sales Order List', compute='_compute_order_invoice_names') invoice_names_jasper = fields.Char(string='Invoice List', compute='_compute_order_invoice_names') @api.depends('refund_type') def _compute_refund_type_display(self): for rec in self: rec.refund_type_display = dict(self.fields_get(allfields=['refund_type'])['refund_type']['selection']).get(rec.refund_type, '') @api.model def create(self, vals): allowed_user_ids = [23, 19, 688, 7] if not ( self.env.user.has_group('indoteknik_custom.group_role_sales') or self.env.user.has_group('indoteknik_custom.group_role_fat') or self.env.user.id not in allowed_user_ids ): raise UserError("❌ Hanya user Sales dan Finance yang boleh membuat refund.") if vals.get('name', 'New') == 'New': vals['name'] = self.env['ir.sequence'].next_by_code('refund.sale.order') or 'New' vals['created_date'] = fields.Date.context_today(self) vals['create_uid'] = self.env.user.id if 'sale_order_ids' in vals: so_cmd = vals['sale_order_ids'] so_ids = so_cmd[0][2] if so_cmd and so_cmd[0][0] == 6 else [] if so_ids: sale_orders = self.env['sale.order'].browse(so_ids) vals['partner_id'] = sale_orders[0].partner_id.id invoices = sale_orders.mapped('invoice_ids').filtered( lambda inv: inv.move_type in ['out_invoice', 'out_refund'] and inv.state != 'cancel' ) if invoices: vals['invoice_ids'] = [(6, 0, invoices.ids)] refund_type = vals.get('refund_type') invoice_ids_data = vals.get('invoice_ids', []) invoice_ids = invoice_ids_data[0][2] if invoice_ids_data and invoice_ids_data[0][0] == 6 else [] if invoice_ids and refund_type and refund_type not in ['uang', 'barang_kosong_sebagian', 'barang_kosong', 'retur_half']: raise UserError("Refund type Hanya Bisa Lebih Bayar, Barang Kosong Sebagian, atau Retur Sebagian jika ada invoice") if not invoice_ids and refund_type and refund_type in ['uang', 'barang_kosong_sebagian', 'barang_kosong', 'retur_half']: raise UserError("Refund type Lebih Bayar, Barang Kosong Sebagian, atau Retur Sebagian Hanya Bisa dipilih Jika Ada Invoice") if not so_ids and refund_type != 'lainnya': raise ValidationError("Jika tidak ada Sales Order yang dipilih, maka Tipe Refund hanya boleh 'Lainnya'.") refund = refund_type in ['retur', 'retur_half'] if refund and so_ids: so = self.env['sale.order'].browse(so_ids) pickings = self.env['stock.picking'].search([ ('state', '=', 'done'), ('picking_type_id', '=', 73), ('sale_id', 'in', so_ids) ]) if not pickings: raise ValidationError(f"SO {', '.join(so.mapped('name'))} tidak melakukan retur barang.") if refund_type == 'retur_half' and not invoice_ids: raise ValidationError(f"SO {', '.join(so.mapped('name'))} belum memiliki invoice untuk Retur Sebagian.") total_invoice = sum(self.env['account.move'].browse(invoice_ids).mapped('amount_total')) if invoice_ids else 0.0 uang_masuk = vals.get('uang_masuk', 0.0) ongkir = vals.get('ongkir', 0.0) pengurangan = total_invoice + ongkir if uang_masuk > pengurangan: vals['amount_refund'] = uang_masuk - pengurangan else: raise UserError("Uang masuk harus lebih besar dari total invoice + ongkir untuk melakukan refund") return super().create(vals) def write(self, vals): allowed_user_ids = [23, 19, 688, 7] if not ( self.env.user.has_group('indoteknik_custom.group_role_sales') or self.env.user.has_group('indoteknik_custom.group_role_fat') or self.env.user.id in allowed_user_ids ): raise UserError("❌ Hanya user Sales dan Finance yang boleh mengedit refund.") for rec in self: if 'sale_order_ids' in vals: so_commands = vals['sale_order_ids'] so_ids = [] for cmd in so_commands: if cmd[0] == 6: so_ids = cmd[2] elif cmd[0] == 4: so_ids.append(cmd[1]) elif cmd[0] == 3: if cmd[1] in so_ids: so_ids.remove(cmd[1]) if so_ids: sale_orders = self.env['sale.order'].browse(so_ids) vals['partner_id'] = sale_orders[0].partner_id.id sale_orders = self.env['sale.order'].browse(so_ids) valid_invoices = sale_orders.mapped('invoice_ids').filtered( lambda inv: inv.move_type in ['out_invoice', 'out_refund'] and inv.state != 'cancel' ) vals['invoice_ids'] = [(6, 0, valid_invoices.ids)] vals['ongkir'] = sum(so.delivery_amt or 0.0 for so in sale_orders) else: so_ids = rec.sale_order_ids.ids sale_orders = self.env['sale.order'].browse(so_ids) refund_type = vals.get('refund_type', rec.refund_type) if not so_ids and refund_type != 'lainnya': raise ValidationError("Jika tidak ada Sales Order yang dipilih, maka Tipe Refund hanya boleh 'Lainnya'.") invoice_ids = vals.get('invoice_ids', False) if invoice_ids: final_invoice_ids = [] for cmd in invoice_ids: if cmd[0] == 6: final_invoice_ids = cmd[2] elif cmd[0] == 4: final_invoice_ids.append(cmd[1]) invoice_ids = final_invoice_ids else: invoice_ids = rec.invoice_ids.ids if invoice_ids and vals.get('refund_type', rec.refund_type) not in ['uang', 'barang_kosong_sebagian', 'barang_kosong', 'retur_half']: raise UserError("Refund type Hanya Bisa Lebih Bayar, Barang Kosong Sebagian, atau Retur Sebagian jika ada invoice") if not invoice_ids and vals.get('refund_type', rec.refund_type) in ['uang', 'barang_kosong_sebagian', 'barang_kosong', 'retur_half']: raise UserError("Refund type Lebih Bayar, Barang Kosong Sebagian, atau Retur Sebagian Hanya Bisa dipilih Jika Ada Invoice") if refund_type in ['retur', 'retur_half'] and so_ids: so = self.env['sale.order'].browse(so_ids) pickings = self.env['stock.picking'].search([ ('state', '=', 'done'), ('picking_type_id', '=', 73), ('sale_id', 'in', so_ids) ]) if not pickings: raise ValidationError(f"SO {', '.join(so.mapped('name'))} tidak melakukan retur barang.") if refund_type == 'retur_half' and not invoice_ids: raise ValidationError(f"SO {', '.join(so.mapped('name'))} belum memiliki invoice untuk retur sebagian.") if any(field in vals for field in ['uang_masuk', 'invoice_ids', 'ongkir', 'sale_order_ids']): total_invoice = sum(self.env['account.move'].browse(invoice_ids).mapped('amount_total')) uang_masuk = vals.get('uang_masuk', rec.uang_masuk) ongkir = vals.get('ongkir', rec.ongkir) if uang_masuk <= (total_invoice + ongkir): raise UserError("Uang masuk harus lebih besar dari total invoice + ongkir") vals['amount_refund'] = uang_masuk - (total_invoice + ongkir) if vals.get('status') == 'refund' and not vals.get('refund_date'): vals['refund_date'] = fields.Date.context_today(self) return super().write(vals) @api.depends('status_payment') def _compute_is_locked(self): for rec in self: rec.is_locked = rec.status_payment in ['done', 'reject'] @api.depends('sale_order_ids.name', 'invoice_ids.name') def _compute_order_invoice_names(self): for rec in self: rec.sale_order_names_jasper = ', '.join(rec.sale_order_ids.mapped('name')) or '' rec.invoice_names_jasper = ', '.join(rec.invoice_ids.mapped('name')) or '' @api.depends('sale_order_ids') def _compute_advance_move_names(self): for rec in self: move_links = [] moves = self.env['account.move'].search([ ('sale_id', 'in', rec.sale_order_ids.ids), ('journal_id', '=', 11), ('state', '=', 'posted') ]) for move in moves: url = f"/web#id={move.id}&model=account.move&view_type=form" name = html_escape(move.name or 'Unnamed') move_links.append(f'{name}') rec.advance_move_names = ', '.join(move_links) if move_links else "-" @api.depends('sale_order_ids.user_id') def _compute_user_ids(self): for rec in self: user_ids = list({so.user_id.id for so in rec.sale_order_ids if so.user_id}) rec.user_ids = [(6, 0, user_ids)] @api.onchange('sale_order_ids') def _onchange_sale_order_ids(self): self.invoice_ids = [(5, 0, 0)] self.line_ids = [(5, 0, 0)] self.ongkir = 0.0 all_invoices = self.env['account.move'] total_invoice = 0.0 for so in self.sale_order_ids: self.ongkir += so.delivery_amt or 0.0 valid_invoices = so.invoice_ids.filtered( lambda inv: inv.move_type in ['out_invoice', 'out_refund'] and inv.state != 'cancel' ) all_invoices |= valid_invoices total_invoice += sum(valid_invoices.mapped('amount_total')) self.invoice_ids = all_invoices self.total_invoice = total_invoice self.refund_type = 'uang' if all_invoices else False pengurangan = total_invoice + self.ongkir if self.uang_masuk > pengurangan: self.amount_refund = self.uang_masuk - pengurangan else: self.amount_refund = 0.0 if self.sale_order_ids: self.partner_id = self.sale_order_ids[0].partner_id @api.onchange('refund_type') def _onchange_refund_type(self): self.line_ids = [(5, 0, 0)] if self.refund_type in ['barang_kosong_sebagian', 'barang_kosong'] and self.sale_order_ids: line_vals = [] for so in self.sale_order_ids: for line in so.order_line: if line.qty_delivered == 0: line_vals.append((0, 0, { 'product_id': line.product_id.id, 'quantity': line.product_uom_qty, 'reason': '', })) self.line_ids = line_vals elif self.refund_type in ['retur', 'retur_half'] and self.sale_order_ids: line_vals = [] StockPicking = self.env['stock.picking'] for so in self.sale_order_ids: pickings = StockPicking.search([ ('state', '=', 'done'), ('picking_type_id', '=', 73), ('sale_id', 'in', so.ids) ]) for picking in pickings: for move in picking.move_lines: line_vals.append((0, 0, { 'product_id': move.product_id.id, 'quantity': move.product_uom_qty, 'reason': '', })) self.line_ids = line_vals @api.depends('invoice_ids') def _compute_invoice_lines(self): for rec in self: lines = self.env['account.move.line'] for inv in rec.invoice_ids: lines |= inv.invoice_line_ids rec.invoice_line_ids = lines @api.depends('amount_refund') def _compute_refund_text(self): tb = Terbilang() for record in self: res = '' try: if record.amount_refund > 0: tb.parse(int(record.amount_refund)) res = tb.getresult().title() record.amount_refund_text = res + ' Rupiah' except: record.amount_refund_text = '' def unlink(self): not_draft = self.filtered(lambda r: r.status != 'draft') if not_draft: names = ', '.join(not_draft.mapped('name')) raise UserError(f"Refund hanya bisa dihapus jika statusnya masih draft.\nTidak bisa hapus: {names}") return super().unlink() @api.depends('invoice_ids') def _compute_invoice_names(self): for rec in self: names = [] for inv in rec.invoice_ids: url = f"/web#id={inv.id}&model=account.move&view_type=form" name = html_escape(inv.name) names.append(f'{name}') rec.invoice_names = ', '.join(names) @api.depends('sale_order_ids') def _compute_so_names(self): for rec in self: so_links = [] for so in rec.sale_order_ids: url = f"/web#id={so.id}&model=sale.order&view_type=form" name = html_escape(so.name) so_links.append(f'{name}') rec.so_names = ', '.join(so_links) if so_links else "-" @api.onchange('uang_masuk', 'total_invoice', 'ongkir') def _onchange_amount_refund(self): for rec in self: pengurangan = rec.total_invoice + rec.ongkir refund = rec.uang_masuk - pengurangan rec.amount_refund = refund if refund > 0 else 0.0 @api.model def default_get(self, fields_list): res = super().default_get(fields_list) sale_order_id = self.env.context.get('default_sale_order_id') if sale_order_id: so = self.env['sale.order'].browse(sale_order_id) res['sale_order_ids'] = [(6, 0, [so.id])] invoice_ids = so.invoice_ids.filtered( lambda inv: inv.move_type in ['out_invoice', 'out_refund'] and inv.state != 'cancel' ).ids res['invoice_ids'] = [(6, 0, invoice_ids)] res['uang_masuk'] = 0.0 res['ongkir'] = so.delivery_amt or 0.0 line_vals = [] for line in so.order_line: line_vals.append((0, 0, { 'product_id': line.product_id.id, 'quantity': line.product_uom_qty, 'reason': '', })) res['line_ids'] = line_vals res['refund_type'] = 'uang' if invoice_ids else False return res @api.onchange('invoice_ids') def _onchange_invoice_ids(self): if self.invoice_ids: if self.refund_type not in ['uang', 'barang_kosong']: self.refund_type = False self.total_invoice = sum(self.invoice_ids.mapped('amount_total')) def action_ask_approval(self): for rec in self: if rec.status == 'draft': rec.status = 'pengajuan1' def _get_status_label(self, code): status_dict = dict(self.fields_get(allfields=['status'])['status']['selection']) return status_dict.get(code, code) def action_approve_flow(self): jakarta_tz = pytz.timezone('Asia/Jakarta') now = datetime.now(jakarta_tz).replace(tzinfo=None) for rec in self: user_name = self.env.user.name if not rec.status or rec.status == 'draft': rec.status = 'pengajuan1' elif rec.status == 'pengajuan1' and self.env.user.id == 19: rec.status = 'pengajuan2' rec.approved_by = f"{rec.approved_by}, {user_name}" if rec.approved_by else user_name rec.date_approved_sales = now rec.position_sales = 'Sales Manager' elif rec.status == 'pengajuan2' and self.env.user.id == 688: rec.status = 'pengajuan3' rec.approved_by = f"{rec.approved_by}, {user_name}" if rec.approved_by else user_name rec.date_approved_ar = now rec.position_ar = 'AR' elif rec.status == 'pengajuan3' and self.env.user.id == 7: rec.status = 'refund' rec.approved_by = f"{rec.approved_by}, {user_name}" if rec.approved_by else user_name rec.date_approved_pimpinan = now rec.position_pimpinan = 'Pimpinan' rec.refund_date = fields.Date.context_today(self) else: raise UserError("❌ Hanya bisa diapproved oleh yang bersangkutan.") def action_trigger_cancel(self): is_fat = self.env.user.has_group('indoteknik_custom.group_role_fat') allowed_user_ids = [19, 688, 7] for rec in self: if self.user.id not in allowed_user_ids and not is_fat: raise UserError("❌ Hanya user yang bersangkutan atau Finance (FAT) yang bisa melakukan penolakan.") if rec.status not in ['refund', 'reject']: rec.status = 'reject' rec.status_payment = 'reject' @api.constrains('status', 'reason_reject') def _check_reason_if_rejected(self): for rec in self: if rec.status == 'reject' and not rec.reason_reject: raise ValidationError("Alasan pembatalan harus diisi ketika status Reject.") def action_confirm_refund(self): is_fat = self.env.user.has_group('indoteknik_custom.group_role_fat') for rec in self: if not is_fat: raise UserError("Hanya Finance yang dapat mengkonfirmasi refund.") if rec.status_payment == 'pending': rec.status_payment = 'done' rec.refund_date = fields.Date.context_today(self) else: raise UserError("Refund hanya bisa dikonfirmasi setelah Approval Pimpinan.") def _compute_approval_label(self): for rec in self: label = 'Approval Done' if rec.status == 'draft': label = 'Approval Sales Manager' elif rec.status == 'pengajuan1': label = 'Approval AR' elif rec.status == 'pengajuan2': label = 'Approval Pimpinan' elif rec.status == 'pengajuan3': label = 'Confirm Refund' rec.approval_button_label = label def action_create_journal_refund(self): is_fat = self.env.user.has_group('indoteknik_custom.group_role_fat') if not is_fat: raise UserError("❌ Akses ditolak. Hanya Finance yang dapat membuat journal refund.") for refund in self: current_time = fields.Datetime.now() has_invoice = any(refund.sale_order_ids.mapped('invoice_ids')) # Penentuan partner (dari SO atau partner_id langsung) partner = ( refund.sale_order_ids[0].partner_id.parent_id or refund.sale_order_ids[0].partner_id ) if refund.sale_order_ids else refund.partner_id # Ambil label refund type refund_type_label = dict( self.fields_get(allfields=['refund_type'])['refund_type']['selection'] ).get(refund.refund_type, '').replace("Refund ", "").upper() if not partner: raise UserError("❌ Partner tidak ditemukan.") # Ref format ref_text = f"REFUND {refund_type_label} {refund.name or ''} {partner.display_name}".upper() # Buat Account Move (Journal Entry) account_move = self.env['account.move'].create({ 'ref': ref_text, 'date': current_time, 'journal_id': 11, 'refund_id': refund.id, 'refund_so_ids': [(6, 0, refund.sale_order_ids.ids)], 'partner_id': partner.id, }) amount = refund.amount_refund second_account_id = 450 if has_invoice else 668 debit_line = { 'move_id': account_move.id, 'account_id': second_account_id, 'partner_id': partner.id, 'currency_id': 12, 'debit': amount, 'credit': 0.0, 'name': ref_text, } credit_line = { 'move_id': account_move.id, 'account_id': 389, # Intransit BCA 'partner_id': partner.id, 'currency_id': 12, 'debit': 0.0, 'credit': amount, 'name': ref_text, } self.env['account.move.line'].create([debit_line, credit_line]) return { 'name': _('Journal Entries'), 'view_mode': 'form', 'res_model': 'account.move', 'type': 'ir.actions.act_window', 'res_id': account_move.id, 'target': 'current' } def _compute_journal_refund_move_id(self): for rec in self: move = self.env['account.move'].search([ ('refund_id', '=', rec.id) ], limit=1) rec.journal_refund_move_id = move def action_open_journal_refund(self): self.ensure_one() if self.journal_refund_move_id: return { 'name': _('Journal Refund'), 'view_mode': 'form', 'res_model': 'account.move', 'type': 'ir.actions.act_window', 'res_id': self.journal_refund_move_id.id, 'target': 'current' } class RefundSaleOrderLine(models.Model): _name = 'refund.sale.order.line' _description = 'Refund Sales Order Line' _inherit = ['mail.thread'] refund_id = fields.Many2one('refund.sale.order', string='Refund Ref') product_id = fields.Many2one('product.product', string='Product') quantity = fields.Float(string='Qty') reason = fields.Char(string='Reason')