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